trackfw 6.5.1 → 6.7.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/package.json +1 -1
- package/src/commands/adr.js +28 -4
- package/src/commands/req.js +16 -1
- package/src/commands/update.js +79 -0
- package/src/generators/adr.js +9 -5
- package/src/generators/hooks.js +203 -18
- package/src/i18n/locales/en-US.json +4 -1
- package/src/i18n/locales/es-ES.json +4 -1
- package/src/i18n/locales/pt-BR.json +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.7.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/commands/adr.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
+
const path = require('path')
|
|
4
|
+
const os = require('os')
|
|
3
5
|
const { Command } = require('commander')
|
|
4
6
|
const { input } = require('@inquirer/prompts')
|
|
5
7
|
const generators = require('../generators/adr')
|
|
@@ -8,9 +10,29 @@ const { t } = require('../i18n')
|
|
|
8
10
|
const cmd = new Command('adr')
|
|
9
11
|
cmd.description(t('adr.description'))
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Resolve o diretório de ADRs conforme --scope.
|
|
15
|
+
* 'project' (default) preserva o comportamento atual (adrDirs[0] do trackfw.yaml).
|
|
16
|
+
* 'global' escreve/lista em ~/.trackfw/adr, sem exigir trackfw.yaml no cwd.
|
|
17
|
+
* @param {string} scope
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function resolveAdrDir(scope) {
|
|
21
|
+
if (scope === 'global') {
|
|
22
|
+
return path.join(os.homedir(), '.trackfw', 'adr')
|
|
23
|
+
}
|
|
24
|
+
if (scope === 'project') {
|
|
25
|
+
return require('../config').load().adrDirs[0]
|
|
26
|
+
}
|
|
27
|
+
console.error(`Invalid --scope value: "${scope}". Expected "project" or "global".`)
|
|
28
|
+
process.exit(1)
|
|
29
|
+
}
|
|
30
|
+
|
|
11
31
|
cmd.command('new <title>')
|
|
12
32
|
.description(t('adr.new.description'))
|
|
13
|
-
.
|
|
33
|
+
.option('--scope <scope>', 'ADR scope: project (default) or global (~/.trackfw/adr)', 'project')
|
|
34
|
+
.action(async (title, options) => {
|
|
35
|
+
const adrDir = resolveAdrDir(options.scope)
|
|
14
36
|
const content = { title }
|
|
15
37
|
// wizard interativo se TTY
|
|
16
38
|
if (process.stdin.isTTY) {
|
|
@@ -19,13 +41,15 @@ cmd.command('new <title>')
|
|
|
19
41
|
content.consequences = await input({ message: t('adr.new.prompt.consequences'), default: '' })
|
|
20
42
|
content.alternatives = await input({ message: t('adr.new.prompt.alternatives'), default: '' })
|
|
21
43
|
}
|
|
22
|
-
await generators.newADR(content)
|
|
44
|
+
await generators.newADR(content, adrDir)
|
|
23
45
|
})
|
|
24
46
|
|
|
25
47
|
cmd.command('list')
|
|
26
48
|
.description(t('adr.list.description'))
|
|
27
|
-
.
|
|
28
|
-
|
|
49
|
+
.option('--scope <scope>', 'ADR scope: project (default) or global (~/.trackfw/adr)', 'project')
|
|
50
|
+
.action(async (options) => {
|
|
51
|
+
const adrDir = resolveAdrDir(options.scope)
|
|
52
|
+
await generators.listADRs(adrDir)
|
|
29
53
|
})
|
|
30
54
|
|
|
31
55
|
module.exports = cmd
|
package/src/commands/req.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
+
const path = require('path')
|
|
3
|
+
const os = require('os')
|
|
2
4
|
const { Command } = require('commander')
|
|
3
5
|
const { listREQs, moveREQ } = require('../generators/req')
|
|
4
6
|
const { t } = require('../i18n')
|
|
@@ -26,6 +28,19 @@ cmd.command('new <title>')
|
|
|
26
28
|
// Form 2 — critérios de aceite
|
|
27
29
|
content.criteria = await input({ message: t('req.new.prompt.criteria'), default: '- [ ]\n- [ ]' })
|
|
28
30
|
|
|
31
|
+
// Escopo dos ADR drafts desta sessão (uma única pergunta, vale para todos)
|
|
32
|
+
const adrScope = await select({
|
|
33
|
+
message: t('req.new.prompt.adrScope'),
|
|
34
|
+
choices: [
|
|
35
|
+
{ name: t('req.new.prompt.adrScopeLocal'), value: 'local' },
|
|
36
|
+
{ name: t('req.new.prompt.adrScopeGlobal'), value: 'global' },
|
|
37
|
+
],
|
|
38
|
+
default: 'local',
|
|
39
|
+
})
|
|
40
|
+
const adrDir = adrScope === 'global'
|
|
41
|
+
? path.join(os.homedir(), '.trackfw', 'adr')
|
|
42
|
+
: require('../config').load().adrDirs[0]
|
|
43
|
+
|
|
29
44
|
// Perguntas dinâmicas por probe
|
|
30
45
|
const generatedADRs = []
|
|
31
46
|
for (const probe of probes) {
|
|
@@ -40,7 +55,7 @@ cmd.command('new <title>')
|
|
|
40
55
|
})
|
|
41
56
|
if (answer) {
|
|
42
57
|
try {
|
|
43
|
-
const basename = await adrGenerators.newADRDraft(answer)
|
|
58
|
+
const basename = await adrGenerators.newADRDraft(answer, adrDir)
|
|
44
59
|
if (basename) generatedADRs.push(basename)
|
|
45
60
|
} catch (e) {
|
|
46
61
|
console.warn(t('req.new.adrWarning', { slug: answer, message: e.message }))
|
package/src/commands/update.js
CHANGED
|
@@ -29,6 +29,83 @@ function loadUpdateConfig(rootDir) {
|
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// ensureGlobalAdrDirRegistered — mirrors internal/generators/update.go's
|
|
33
|
+
// ensureGlobalADRDirRegistered (Go implementation, same REQ, ML-1A). Registers
|
|
34
|
+
// ~/.trackfw/adr in the project's trackfw.yaml `adr_dirs` list, but ONLY when
|
|
35
|
+
// that directory exists AND contains at least one `ADR-*.md` file — an empty
|
|
36
|
+
// or absent global ADR dir is a no-op, never written. The edit is surgical
|
|
37
|
+
// (line-based text splice, not a YAML parse+reserialize) to preserve 100% of
|
|
38
|
+
// the user's existing trackfw.yaml content (comments, key order, formatting).
|
|
39
|
+
function ensureGlobalAdrDirRegistered(cwd) {
|
|
40
|
+
const home = os.homedir();
|
|
41
|
+
const globalDir = path.join(home, '.trackfw', 'adr');
|
|
42
|
+
if (!fs.existsSync(globalDir)) return;
|
|
43
|
+
|
|
44
|
+
let hasAdr = false;
|
|
45
|
+
try {
|
|
46
|
+
hasAdr = fs.readdirSync(globalDir).some((f) => /^ADR-.*\.md$/.test(f));
|
|
47
|
+
} catch (_) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!hasAdr) return;
|
|
51
|
+
|
|
52
|
+
const yamlPath = path.join(cwd, 'trackfw.yaml');
|
|
53
|
+
let content;
|
|
54
|
+
try {
|
|
55
|
+
content = fs.readFileSync(yamlPath, 'utf8');
|
|
56
|
+
} catch (_) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const resolvesToGlobal = (entry) => {
|
|
61
|
+
const trimmed = entry.trim();
|
|
62
|
+
if (trimmed === '~/.trackfw/adr') return true;
|
|
63
|
+
const expanded = trimmed.startsWith('~') ? path.join(home, trimmed.slice(1)) : trimmed;
|
|
64
|
+
return path.resolve(expanded) === path.resolve(globalDir);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const lines = content.split('\n');
|
|
68
|
+
let adrDirsIndex = -1;
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
if (/^adr_dirs:\s*$/.test(lines[i])) {
|
|
71
|
+
adrDirsIndex = i;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (adrDirsIndex === -1) {
|
|
77
|
+
// No adr_dirs key at all — the implicit default is `docs/adr`; append a
|
|
78
|
+
// new block preserving that default explicitly, plus the global entry.
|
|
79
|
+
let newContent = content;
|
|
80
|
+
if (newContent.length && !newContent.endsWith('\n')) newContent += '\n';
|
|
81
|
+
newContent += 'adr_dirs:\n - docs/adr\n - ~/.trackfw/adr\n';
|
|
82
|
+
fs.writeFileSync(yamlPath, newContent, 'utf8');
|
|
83
|
+
console.log(' ✓ adr_dirs: ~/.trackfw/adr registrado');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const itemRe = /^(\s*)-\s*(.+?)\s*$/;
|
|
88
|
+
let end = adrDirsIndex + 1;
|
|
89
|
+
let lastItemIndex = -1;
|
|
90
|
+
let indent = ' ';
|
|
91
|
+
while (end < lines.length) {
|
|
92
|
+
const m = lines[end].match(itemRe);
|
|
93
|
+
if (!m) break;
|
|
94
|
+
indent = m[1] || indent;
|
|
95
|
+
if (resolvesToGlobal(m[2])) {
|
|
96
|
+
// Already registered under this or an equivalent path form — no-op.
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
lastItemIndex = end;
|
|
100
|
+
end++;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const insertAt = lastItemIndex === -1 ? adrDirsIndex + 1 : lastItemIndex + 1;
|
|
104
|
+
lines.splice(insertAt, 0, `${indent}- ~/.trackfw/adr`);
|
|
105
|
+
fs.writeFileSync(yamlPath, lines.join('\n'), 'utf8');
|
|
106
|
+
console.log(' ✓ adr_dirs: ~/.trackfw/adr registrado');
|
|
107
|
+
}
|
|
108
|
+
|
|
32
109
|
function updateHooksSurgical(cfg, rootDir) {
|
|
33
110
|
const hooks = cfg.hooks || '';
|
|
34
111
|
if (hooks === 'husky') {
|
|
@@ -243,6 +320,8 @@ cmd.action((mode, options) => {
|
|
|
243
320
|
process.exit(1);
|
|
244
321
|
}
|
|
245
322
|
|
|
323
|
+
ensureGlobalAdrDirRegistered(cwd);
|
|
324
|
+
|
|
246
325
|
let wanted;
|
|
247
326
|
try {
|
|
248
327
|
const requested = options.targets ? String(options.targets).split(',').map((s) => s.trim()).filter(Boolean) : [];
|
package/src/generators/adr.js
CHANGED
|
@@ -43,13 +43,13 @@ function today() {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* Cria um novo ADR em
|
|
46
|
+
* Cria um novo ADR em <adrDir>/ADR-YYYY-MM-DD-<slug>.md.
|
|
47
47
|
* Campos vazios recebem placeholder HTML.
|
|
48
48
|
* @param {{ title: string, context?: string, decision?: string, consequences?: string, alternatives?: string }} content
|
|
49
|
+
* @param {string} adrDir Diretório de destino (resolvido pelo chamador conforme --scope)
|
|
49
50
|
* @returns {Promise<void>}
|
|
50
51
|
*/
|
|
51
|
-
async function newADR(content) {
|
|
52
|
-
const adrDir = require('../config').load().adrDirs[0]
|
|
52
|
+
async function newADR(content, adrDir) {
|
|
53
53
|
fs.mkdirSync(adrDir, { recursive: true })
|
|
54
54
|
|
|
55
55
|
const slug = toSlug(content.title)
|
|
@@ -142,10 +142,14 @@ function parseADRStatus(filepath) {
|
|
|
142
142
|
* Cria um ADR com Status: Draft a partir de um slug.
|
|
143
143
|
* Idempotente: se já existe ADR-*-<slug>.md, pula e imprime mensagem.
|
|
144
144
|
* @param {string} slug
|
|
145
|
+
* @param {string} [adrDir] Diretório de destino (resolvido pelo chamador conforme escopo).
|
|
146
|
+
* Se omitido, preserva o comportamento anterior (adrDirs[0] do trackfw.yaml).
|
|
145
147
|
* @returns {Promise<string>} basename do arquivo criado
|
|
146
148
|
*/
|
|
147
|
-
async function newADRDraft(slug) {
|
|
148
|
-
|
|
149
|
+
async function newADRDraft(slug, adrDir) {
|
|
150
|
+
if (!adrDir) {
|
|
151
|
+
adrDir = require('../config').load().adrDirs[0]
|
|
152
|
+
}
|
|
149
153
|
fs.mkdirSync(adrDir, { recursive: true })
|
|
150
154
|
|
|
151
155
|
// Verificar idempotência: buscar arquivo existente com o mesmo slug
|
package/src/generators/hooks.js
CHANGED
|
@@ -174,8 +174,6 @@ elif printf '%s' "$INPUT" | grep -qE "$AWS_KEY_PATTERN"; then
|
|
|
174
174
|
MATCH="AWS access key"
|
|
175
175
|
fi
|
|
176
176
|
|
|
177
|
-
[ -n "$MATCH" ] || exit 0
|
|
178
|
-
|
|
179
177
|
# The raw payload is JSON: any double quote inside the underlying tool_input.command is
|
|
180
178
|
# escaped as \\" -- unescape those before scanning for redirect targets, or a quoted target
|
|
181
179
|
# like "$TMPFILE" is seen as starting with a literal backslash instead of a variable
|
|
@@ -206,6 +204,57 @@ is_ephemeral_target() {
|
|
|
206
204
|
|
|
207
205
|
REDIRECTS=$(printf '%s' "$RAW" | grep -oE '[0-9]?>>?[[:space:]]*[^[:space:]|&;,:]+' || true)
|
|
208
206
|
|
|
207
|
+
# Second detection layer: only runs when the payload scan above found nothing -- keeps the common
|
|
208
|
+
# case (match already found) cheap and avoids reading files unnecessarily. Files above the size cap
|
|
209
|
+
# are skipped silently.
|
|
210
|
+
scan_file_for_pattern() {
|
|
211
|
+
local path size
|
|
212
|
+
path=$(printf '%s' "$1" | tr -d "\\"'" | sed -E 's/[},]+$//')
|
|
213
|
+
[ -n "$path" ] && [ -f "$path" ] || return 1
|
|
214
|
+
size=$(wc -c < "$path" 2>/dev/null | tr -d '[:space:]')
|
|
215
|
+
size=\${size:-0}
|
|
216
|
+
[ "$size" -lt 1048576 ] || return 1
|
|
217
|
+
if grep -qE "$JWT_PATTERN" "$path" 2>/dev/null; then
|
|
218
|
+
MATCH="JWT"
|
|
219
|
+
return 0
|
|
220
|
+
fi
|
|
221
|
+
if grep -qE "$AWS_KEY_PATTERN" "$path" 2>/dev/null; then
|
|
222
|
+
MATCH="AWS access key"
|
|
223
|
+
return 0
|
|
224
|
+
fi
|
|
225
|
+
return 1
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if [ -z "$MATCH" ] && [ -n "$REDIRECTS" ]; then
|
|
229
|
+
while IFS= read -r line; do
|
|
230
|
+
if [ -z "$line" ]; then
|
|
231
|
+
continue
|
|
232
|
+
fi
|
|
233
|
+
target=$(printf '%s' "$line" | sed -E 's/^[0-9]?>>?[[:space:]]*//')
|
|
234
|
+
if ! is_ephemeral_target "$target"; then
|
|
235
|
+
scan_file_for_pattern "$target" && break
|
|
236
|
+
fi
|
|
237
|
+
done <<< "$REDIRECTS"
|
|
238
|
+
fi
|
|
239
|
+
|
|
240
|
+
if [ -z "$MATCH" ]; then
|
|
241
|
+
CMD_LINE=$(printf '%s' "$RAW" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p')
|
|
242
|
+
if [ -n "$CMD_LINE" ]; then
|
|
243
|
+
set -- $CMD_LINE
|
|
244
|
+
cmd_name="\${1:-}"
|
|
245
|
+
case "$cmd_name" in
|
|
246
|
+
cat|head|tail|jq|grep)
|
|
247
|
+
shift
|
|
248
|
+
for token in "$@"; do
|
|
249
|
+
scan_file_for_pattern "$token" && break
|
|
250
|
+
done
|
|
251
|
+
;;
|
|
252
|
+
esac
|
|
253
|
+
fi
|
|
254
|
+
fi
|
|
255
|
+
|
|
256
|
+
[ -n "$MATCH" ] || exit 0
|
|
257
|
+
|
|
209
258
|
HAS_REDIRECT=0
|
|
210
259
|
EXEMPT=1
|
|
211
260
|
if [ -n "$REDIRECTS" ]; then
|
|
@@ -227,10 +276,22 @@ fi
|
|
|
227
276
|
|
|
228
277
|
`
|
|
229
278
|
|
|
230
|
-
|
|
279
|
+
// Resolução de MODE (grep de `credential_guard.mode` em trackfw.yaml + fallback) é inlined,
|
|
280
|
+
// idêntica em CG_PROJECT_TAIL (fallback "warn") e CG_GLOBAL_TAIL (fallback "block") — ao invés de
|
|
281
|
+
// extrair para uma constante JS compartilhada e concatenar (como o Go faz via
|
|
282
|
+
// credentialGuardModeResolution/DEFAULT_MODE), aqui o texto é replicado como literal em cada
|
|
283
|
+
// template literal: o gate de paridade Go/Node/Python
|
|
284
|
+
// (internal/generators/credential_guard_test.go, getNodeSourceBlock) extrai cada constante via
|
|
285
|
+
// regex de um único bloco `` `const NAME = \`...\`` `` sem suportar concatenação de string —
|
|
286
|
+
// concatenar quebraria a extração estática. Nunca editar a lógica de resolução em só um dos dois
|
|
287
|
+
// blocos sem replicar no outro (ML-1B, ADR-2026-08-06 emenda 6). Mirrors Go's
|
|
288
|
+
// credentialGuardModeResolution (semântica idêntica, forma sintática diferente por essa restrição
|
|
289
|
+
// do parser de paridade).
|
|
290
|
+
const CG_PROJECT_TAIL = `DEFAULT_MODE="warn"
|
|
291
|
+
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)
|
|
231
292
|
case "$MODE" in
|
|
232
293
|
warn|block) ;;
|
|
233
|
-
*) MODE="
|
|
294
|
+
*) MODE="$DEFAULT_MODE" ;;
|
|
234
295
|
esac
|
|
235
296
|
|
|
236
297
|
if [ "$MODE" = "block" ]; then
|
|
@@ -259,10 +320,42 @@ printf '{"tool":"credential-guard","message":"%s","level":"action_required","tim
|
|
|
259
320
|
exit 0
|
|
260
321
|
`
|
|
261
322
|
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
323
|
+
// CG_GLOBAL_TAIL é a contraparte de CG_PROJECT_TAIL para o escopo global
|
|
324
|
+
// (~/.trackfw/scripts/trackfw-credential-guard.sh, instalado via `trackfw update harness`).
|
|
325
|
+
//
|
|
326
|
+
// Decisão (ML-1B, ver ADR-2026-08-06 emenda 6 de 2026-08-08 e ROADMAP-2026-08-08, Wave 1): o modo
|
|
327
|
+
// em escopo global reusa a MESMA leitura de `credential_guard.mode` de trackfw.yaml que
|
|
328
|
+
// CG_PROJECT_TAIL já faz (mesma resolução, replicada aqui — ver o comentário de CG_PROJECT_TAIL
|
|
329
|
+
// sobre por que não é extraída para uma constante compartilhada em Node) — sem exigir trackfw.yaml
|
|
330
|
+
// existir (não há o guard `[ -f trackfw.yaml ] || exit 0` da variante de projeto: o objetivo do
|
|
331
|
+
// escopo global é proteger qualquer projeto, com ou sem trackfw.yaml). Quando o hook global roda a
|
|
332
|
+
// partir do cwd de um projeto com trackfw.yaml e credential_guard.mode explícito, esse valor é
|
|
333
|
+
// respeitado (warn ou block) — nenhuma mudança de comportamento para quem já definiu mode: warn
|
|
334
|
+
// explicitamente. Em qualquer outro caso (sem trackfw.yaml, ou trackfw.yaml sem essa chave), o
|
|
335
|
+
// fallback deixa de ser "warn" e passa a ser "block": um guard opt-in que nunca bloqueia por
|
|
336
|
+
// padrão é uma falsa sensação de proteção — o usuário que rodou `trackfw update harness` já
|
|
337
|
+
// demonstrou intenção explícita de ter o mecanismo ativo. Supersede a decisão original ("modo
|
|
338
|
+
// global sempre warn", opção "b" avaliada na ADR original) — não cria ~/.trackfw/config.yaml nem
|
|
339
|
+
// nenhuma outra segunda fonte de configuração só para isto.
|
|
340
|
+
//
|
|
341
|
+
// ROADMAP_DIR em escopo global: como não há garantia de trackfw.yaml para ler `roadmap_dir:`, o
|
|
342
|
+
// script usa o caminho padrão fixo "docs/roadmaps" relativo ao cwd de onde o hook foi disparado, e
|
|
343
|
+
// só grava o attention signal se esse diretório já existir (e só em modo warn — modo block nunca
|
|
344
|
+
// grava o attention signal, mesma decisão da variante de projeto). Não cria "docs/roadmaps" em um
|
|
345
|
+
// projeto aleatório só para sinalizar isso — isso pareceria ao usuário que o trackfw foi
|
|
346
|
+
// "instalado" nesse projeto, o que não é verdade. O texto de warning/block em stderr acontece
|
|
347
|
+
// sempre (visível no output do CLI/hook), independente de o diretório de attention existir.
|
|
348
|
+
const CG_GLOBAL_TAIL = `DEFAULT_MODE="block"
|
|
349
|
+
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)
|
|
350
|
+
case "$MODE" in
|
|
351
|
+
warn|block) ;;
|
|
352
|
+
*) MODE="$DEFAULT_MODE" ;;
|
|
353
|
+
esac
|
|
354
|
+
|
|
355
|
+
if [ "$MODE" = "block" ]; then
|
|
356
|
+
echo "trackfw-credential-guard: blocked - possible $MATCH detected in tool payload." >&2
|
|
357
|
+
exit 2
|
|
358
|
+
fi
|
|
266
359
|
|
|
267
360
|
echo "trackfw-credential-guard: warning - possible $MATCH detected in tool payload." >&2
|
|
268
361
|
|
|
@@ -459,14 +552,21 @@ function injectClaudeHooks(cwd) {
|
|
|
459
552
|
|
|
460
553
|
if (!data.hooks) data.hooks = {}
|
|
461
554
|
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'AskUserQuestion', SIGNAL_CMD)
|
|
462
|
-
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A
|
|
463
|
-
// credential-guard when the global one is
|
|
555
|
+
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
|
|
556
|
+
// Wave 2 to Read/Write|Edit): skip project-scope credential-guard when the global one is
|
|
557
|
+
// already installed for this CLI.
|
|
464
558
|
if (!globalCredentialGuardInstalledClaude()) {
|
|
465
559
|
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GUARD_CMD)
|
|
560
|
+
// ADR-2026-08-06 emenda 7 (2026-08-08): Read/Write/Edit coverage — extraction via direct
|
|
561
|
+
// file read, or materialization via write/edit, never went through the hook before.
|
|
562
|
+
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Read', GUARD_CMD)
|
|
563
|
+
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Write|Edit', GUARD_CMD)
|
|
466
564
|
}
|
|
467
565
|
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'AskUserQuestion', CLEANUP_CMD)
|
|
468
566
|
if (!globalCredentialGuardInstalledClaude()) {
|
|
469
567
|
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Bash', GUARD_CMD)
|
|
568
|
+
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Read', GUARD_CMD)
|
|
569
|
+
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Write|Edit', GUARD_CMD)
|
|
470
570
|
}
|
|
471
571
|
|
|
472
572
|
writeJSON(filePath, data)
|
|
@@ -483,6 +583,12 @@ function injectClaudeHooks(cwd) {
|
|
|
483
583
|
// are enabled by default (no `[features] hooks = true`/`codex_hooks` opt-in
|
|
484
584
|
// needed -- that flag exists only to turn hooks OFF), and PreToolUse blocking
|
|
485
585
|
// uses exit code 2 + stderr (matching trackfw-credential-guard.sh's "block" mode).
|
|
586
|
+
//
|
|
587
|
+
// Read/Write/Edit coverage (ADR-2026-08-06 emenda 7, ROADMAP-2026-08-08 Wave 2, 2026-08-08):
|
|
588
|
+
// Codex has NO dedicated, interceptable read-tool matcher -- confirmed against
|
|
589
|
+
// https://learn.chatgpt.com/docs/hooks -- so no read matcher is added here; this is a documented
|
|
590
|
+
// limitation (also called out in docs/cli-parity.md), not a workaround. Write/edit materialization
|
|
591
|
+
// IS covered via the `apply_patch` matcher (documented aliases `Edit`/`Write`).
|
|
486
592
|
// ---------------------------------------------------------------------------
|
|
487
593
|
|
|
488
594
|
function injectCodexHooks(cwd) {
|
|
@@ -491,15 +597,18 @@ function injectCodexHooks(cwd) {
|
|
|
491
597
|
|
|
492
598
|
if (!data.hooks) data.hooks = {}
|
|
493
599
|
data.hooks.PermissionRequest = mergeClaudeHookArray(data.hooks.PermissionRequest, '.*', SIGNAL_CMD)
|
|
494
|
-
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A
|
|
495
|
-
// credential-guard when the global one is already
|
|
600
|
+
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
|
|
601
|
+
// Wave 2 to apply_patch): skip project-scope credential-guard when the global one is already
|
|
602
|
+
// installed for this CLI.
|
|
496
603
|
const skipCodexCG = globalCredentialGuardInstalledCodex()
|
|
497
604
|
if (!skipCodexCG) {
|
|
498
605
|
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GUARD_CMD)
|
|
606
|
+
data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'apply_patch', GUARD_CMD)
|
|
499
607
|
}
|
|
500
608
|
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, '.*', CLEANUP_CMD)
|
|
501
609
|
if (!skipCodexCG) {
|
|
502
610
|
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Bash', GUARD_CMD)
|
|
611
|
+
data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'apply_patch', GUARD_CMD)
|
|
503
612
|
}
|
|
504
613
|
|
|
505
614
|
writeJSON(filePath, data)
|
|
@@ -523,6 +632,12 @@ function injectCodexHooks(cwd) {
|
|
|
523
632
|
// (matcher "*") is the pre-existing attention-cleanup wiring, unrelated to the new
|
|
524
633
|
// credential-guard entry added as a separate array entry (different matcher) in the
|
|
525
634
|
// same event.
|
|
635
|
+
//
|
|
636
|
+
// Read/Write/Edit coverage (ADR-2026-08-06 emenda 7, ROADMAP-2026-08-08 Wave 2, 2026-08-08): the
|
|
637
|
+
// Gemini CLI tools table (https://geminicli.com/docs/reference/tools) documents `read_file`/
|
|
638
|
+
// `read_many_files` as the file-read tools and `write_file`/`replace` as the file-write/edit
|
|
639
|
+
// tools -- matcher below follows the same regex-over-tool_name convention already used for
|
|
640
|
+
// `run_shell_command`.
|
|
526
641
|
// ---------------------------------------------------------------------------
|
|
527
642
|
|
|
528
643
|
function injectGeminiHooks(cwd) {
|
|
@@ -531,15 +646,20 @@ function injectGeminiHooks(cwd) {
|
|
|
531
646
|
|
|
532
647
|
if (!data.hooks) data.hooks = {}
|
|
533
648
|
data.hooks.Notification = mergeClaudeHookArray(data.hooks.Notification, 'ToolPermission', SIGNAL_CMD)
|
|
534
|
-
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A
|
|
649
|
+
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
|
|
650
|
+
// Wave 2 to read_file|read_many_files / write_file|replace): skip project-scope
|
|
535
651
|
// credential-guard when the global one is already installed.
|
|
536
652
|
const skipGeminiCG = globalCredentialGuardInstalledGemini()
|
|
537
653
|
if (!skipGeminiCG) {
|
|
538
654
|
data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'run_shell_command', GUARD_CMD)
|
|
655
|
+
data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'read_file|read_many_files', GUARD_CMD)
|
|
656
|
+
data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'write_file|replace', GUARD_CMD)
|
|
539
657
|
}
|
|
540
658
|
data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, '*', CLEANUP_CMD)
|
|
541
659
|
if (!skipGeminiCG) {
|
|
542
660
|
data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'run_shell_command', GUARD_CMD)
|
|
661
|
+
data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'read_file|read_many_files', GUARD_CMD)
|
|
662
|
+
data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'write_file|replace', GUARD_CMD)
|
|
543
663
|
}
|
|
544
664
|
|
|
545
665
|
writeJSON(filePath, data)
|
|
@@ -586,8 +706,9 @@ function injectKiroHooks(cwd) {
|
|
|
586
706
|
},
|
|
587
707
|
]
|
|
588
708
|
|
|
589
|
-
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A
|
|
590
|
-
// credential-guard entries when the global one is
|
|
709
|
+
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
|
|
710
|
+
// Wave 2 to read/write): skip project-scope credential-guard entries when the global one is
|
|
711
|
+
// already installed.
|
|
591
712
|
if (!globalCredentialGuardInstalledKiro()) {
|
|
592
713
|
hooks.push(
|
|
593
714
|
{
|
|
@@ -604,6 +725,36 @@ function injectKiroHooks(cwd) {
|
|
|
604
725
|
matcher: 'shell',
|
|
605
726
|
action: { type: 'command', command: GUARD_CMD },
|
|
606
727
|
},
|
|
728
|
+
// Read/Write coverage (ADR-2026-08-06 emenda 7, 2026-08-08): "read" and "write" are the
|
|
729
|
+
// documented Kiro tool-category aliases (fs_read/fs_write), same pattern as "shell" above.
|
|
730
|
+
{
|
|
731
|
+
name: 'trackfw-credential-guard-read-pre',
|
|
732
|
+
description: 'Blocks/warns on possible plaintext credential materialization before a file read',
|
|
733
|
+
trigger: 'PreToolUse',
|
|
734
|
+
matcher: 'read',
|
|
735
|
+
action: { type: 'command', command: GUARD_CMD },
|
|
736
|
+
},
|
|
737
|
+
{
|
|
738
|
+
name: 'trackfw-credential-guard-read-post',
|
|
739
|
+
description: 'Warns on possible plaintext credential materialization after a file read',
|
|
740
|
+
trigger: 'PostToolUse',
|
|
741
|
+
matcher: 'read',
|
|
742
|
+
action: { type: 'command', command: GUARD_CMD },
|
|
743
|
+
},
|
|
744
|
+
{
|
|
745
|
+
name: 'trackfw-credential-guard-write-pre',
|
|
746
|
+
description: 'Blocks/warns on possible plaintext credential materialization before a file write',
|
|
747
|
+
trigger: 'PreToolUse',
|
|
748
|
+
matcher: 'write',
|
|
749
|
+
action: { type: 'command', command: GUARD_CMD },
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
name: 'trackfw-credential-guard-write-post',
|
|
753
|
+
description: 'Warns on possible plaintext credential materialization after a file write',
|
|
754
|
+
trigger: 'PostToolUse',
|
|
755
|
+
matcher: 'write',
|
|
756
|
+
action: { type: 'command', command: GUARD_CMD },
|
|
757
|
+
},
|
|
607
758
|
)
|
|
608
759
|
}
|
|
609
760
|
|
|
@@ -638,17 +789,27 @@ function injectKiroHooks(cwd) {
|
|
|
638
789
|
// regardless.
|
|
639
790
|
// ---------------------------------------------------------------------------
|
|
640
791
|
|
|
792
|
+
// Read/Write coverage (ADR-2026-08-06 emenda 7, ROADMAP-2026-08-08 Wave 2, 2026-08-08):
|
|
793
|
+
// https://docs.github.com/en/copilot/reference/hooks-reference confirms the camelCase
|
|
794
|
+
// preToolUse/postToolUse toolName mapping `view -> Read`, `create -> Write`, `edit -> Edit` --
|
|
795
|
+
// "view" is the read matcher, "create|edit" the write/edit matcher, same lowercase-runtime-name
|
|
796
|
+
// convention already used for "bash" above.
|
|
641
797
|
function injectCopilotHooks(cwd) {
|
|
642
798
|
const filePath = path.join(cwd, '.github', 'hooks', 'trackfw-attention.json')
|
|
643
799
|
|
|
644
800
|
const preToolUse = [{ type: 'command', bash: SIGNAL_CMD, cwd: '.', timeoutSec: 10 }]
|
|
645
801
|
const postToolUse = [{ type: 'command', bash: CLEANUP_CMD, cwd: '.', timeoutSec: 10 }]
|
|
646
802
|
|
|
647
|
-
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A
|
|
648
|
-
// credential-guard entries when the global
|
|
803
|
+
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
|
|
804
|
+
// Wave 2 to view / create|edit): skip project-scope credential-guard entries when the global
|
|
805
|
+
// one is already installed.
|
|
649
806
|
if (!globalCredentialGuardInstalledCopilot()) {
|
|
650
807
|
preToolUse.push({ type: 'command', matcher: 'bash', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
|
|
808
|
+
preToolUse.push({ type: 'command', matcher: 'view', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
|
|
809
|
+
preToolUse.push({ type: 'command', matcher: 'create|edit', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
|
|
651
810
|
postToolUse.push({ type: 'command', matcher: 'bash', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
|
|
811
|
+
postToolUse.push({ type: 'command', matcher: 'view', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
|
|
812
|
+
postToolUse.push({ type: 'command', matcher: 'create|edit', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
|
|
652
813
|
}
|
|
653
814
|
|
|
654
815
|
const data = {
|
|
@@ -745,7 +906,8 @@ function injectCursorHooks(cwd) {
|
|
|
745
906
|
}
|
|
746
907
|
removeKnownCommandFromLegacyTopLevelArray(data, 'postToolUse', CLEANUP_CMD)
|
|
747
908
|
|
|
748
|
-
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A
|
|
909
|
+
// Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A, extended ADR-2026-08-06 emenda 7/ROADMAP-2026-08-08
|
|
910
|
+
// Wave 2 to Read/Write via the generic preToolUse/postToolUse events): skip project-scope
|
|
749
911
|
// credential-guard entries when the global one is already installed.
|
|
750
912
|
if (!globalCredentialGuardInstalledCursor()) {
|
|
751
913
|
if (!Array.isArray(data.hooks.beforeShellExecution)) data.hooks.beforeShellExecution = []
|
|
@@ -757,6 +919,29 @@ function injectCursorHooks(cwd) {
|
|
|
757
919
|
if (!hasEntry(data.hooks.afterShellExecution, 'command', GUARD_CMD)) {
|
|
758
920
|
data.hooks.afterShellExecution.push({ command: GUARD_CMD })
|
|
759
921
|
}
|
|
922
|
+
|
|
923
|
+
// Read/Write coverage (ADR-2026-08-06 emenda 7, 2026-08-08): wired via the generic
|
|
924
|
+
// preToolUse/postToolUse events (distinct from beforeShellExecution/afterShellExecution,
|
|
925
|
+
// which only ever fire for Shell) with an explicit `matcher`, so these entries never fire
|
|
926
|
+
// for the same tool call the unfiltered attention-signal/cleanup entries already handle
|
|
927
|
+
// above in this same array. hasEntry (command-only) is not enough here -- both the
|
|
928
|
+
// unfiltered signal entry and these matcher-scoped guard entries share the same array, so
|
|
929
|
+
// dedup must also check `matcher`.
|
|
930
|
+
const hasGuardMatcherEntry = (arr, matcher) =>
|
|
931
|
+
Array.isArray(arr) && arr.some(e => e && e.command === GUARD_CMD && e.matcher === matcher)
|
|
932
|
+
|
|
933
|
+
if (!hasGuardMatcherEntry(data.hooks.preToolUse, 'Read')) {
|
|
934
|
+
data.hooks.preToolUse.push({ command: GUARD_CMD, matcher: 'Read' })
|
|
935
|
+
}
|
|
936
|
+
if (!hasGuardMatcherEntry(data.hooks.preToolUse, 'Write')) {
|
|
937
|
+
data.hooks.preToolUse.push({ command: GUARD_CMD, matcher: 'Write' })
|
|
938
|
+
}
|
|
939
|
+
if (!hasGuardMatcherEntry(data.hooks.postToolUse, 'Read')) {
|
|
940
|
+
data.hooks.postToolUse.push({ command: GUARD_CMD, matcher: 'Read' })
|
|
941
|
+
}
|
|
942
|
+
if (!hasGuardMatcherEntry(data.hooks.postToolUse, 'Write')) {
|
|
943
|
+
data.hooks.postToolUse.push({ command: GUARD_CMD, matcher: 'Write' })
|
|
944
|
+
}
|
|
760
945
|
}
|
|
761
946
|
|
|
762
947
|
writeJSON(filePath, data)
|
|
@@ -63,7 +63,10 @@
|
|
|
63
63
|
"domainQuestion_persistence": "Which database engine will be used?",
|
|
64
64
|
"domainQuestion_api": "Which API protocol will be used?",
|
|
65
65
|
"domainQuestion_deploy": "What is the deployment target?",
|
|
66
|
-
"domainQuestion_events": "Which event broker will be used?"
|
|
66
|
+
"domainQuestion_events": "Which event broker will be used?",
|
|
67
|
+
"adrScope": "Scope of ADRs for this REQ",
|
|
68
|
+
"adrScopeLocal": "Local (default)",
|
|
69
|
+
"adrScopeGlobal": "Global (~/.trackfw/adr, cross-project)"
|
|
67
70
|
},
|
|
68
71
|
"detectedDomains": "Detected domains: {{domains}}",
|
|
69
72
|
"created": "✓ REQ created: {{path}}",
|
|
@@ -63,7 +63,10 @@
|
|
|
63
63
|
"domainQuestion_persistence": "¿Qué motor de base de datos se usará?",
|
|
64
64
|
"domainQuestion_api": "¿Qué protocolo de API se usará?",
|
|
65
65
|
"domainQuestion_deploy": "¿Cuál es el destino de despliegue?",
|
|
66
|
-
"domainQuestion_events": "¿Qué message broker se usará?"
|
|
66
|
+
"domainQuestion_events": "¿Qué message broker se usará?",
|
|
67
|
+
"adrScope": "Alcance de los ADRs de este REQ",
|
|
68
|
+
"adrScopeLocal": "Local (predeterminado)",
|
|
69
|
+
"adrScopeGlobal": "Global (~/.trackfw/adr, entre proyectos)"
|
|
67
70
|
},
|
|
68
71
|
"detectedDomains": "Dominios detectados: {{domains}}",
|
|
69
72
|
"created": "✓ REQ creado: {{path}}",
|
|
@@ -63,7 +63,10 @@
|
|
|
63
63
|
"domainQuestion_persistence": "Qual engine de banco de dados será usada?",
|
|
64
64
|
"domainQuestion_api": "Qual protocolo de API será usado?",
|
|
65
65
|
"domainQuestion_deploy": "Qual é o destino de deploy?",
|
|
66
|
-
"domainQuestion_events": "Qual message broker será usado?"
|
|
66
|
+
"domainQuestion_events": "Qual message broker será usado?",
|
|
67
|
+
"adrScope": "Escopo dos ADRs desta REQ",
|
|
68
|
+
"adrScopeLocal": "Local (padrão)",
|
|
69
|
+
"adrScopeGlobal": "Global (~/.trackfw/adr, entre projetos)"
|
|
67
70
|
},
|
|
68
71
|
"detectedDomains": "Domínios detectados: {{domains}}",
|
|
69
72
|
"created": "✓ REQ criado: {{path}}",
|