trackfw 6.4.1 → 6.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trackfw",
3
- "version": "6.4.1",
3
+ "version": "6.5.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",
@@ -441,6 +441,12 @@ cmd.action((opts) => {
441
441
  } catch (e) {
442
442
  console.warn(`⚠ attention scripts: ${e.message}`);
443
443
  }
444
+ try {
445
+ const { generateCredentialGuardScript } = require('../generators/hooks');
446
+ generateCredentialGuardScript(cwd);
447
+ } catch (e) {
448
+ console.warn(`⚠ credential guard script: ${e.message}`);
449
+ }
444
450
  try {
445
451
  const { injectHooksDetected } = require('../generators/hooks');
446
452
  injectHooksDetected(cwd);
@@ -6,6 +6,7 @@ const path = require('path')
6
6
  const identityStore = require('../identity')
7
7
  const { catalog, buildPlans, IntegrationManager, globalGroupPath } = require('../integrations')
8
8
  const { tildeify, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine')
9
+ const { mergeClaudeHookArray, mergeSimpleCommandArray, mergeCopilotHookArray } = require('../generators/hooks')
9
10
 
10
11
  // `trackfw update harness` is the global counterpart to `trackfw update` —
11
12
  // see docs/cli-parity.md, "`trackfw update` vs `trackfw update harness`".
@@ -93,6 +94,374 @@ function claudeSkillTarget(homeRoot, { dryRun, installMissing }) {
93
94
  }
94
95
  }
95
96
 
97
+ // credentialGuardTargetClaude — evaluates (and, unless --dry-run, applies) the
98
+ // global-scope credential-guard hook wiring for Claude Code:
99
+ // PreToolUse[matcher:"Bash"]/PostToolUse[matcher:"Bash"] entries in
100
+ // ~/.claude/settings.json pointing at the ABSOLUTE path of
101
+ // ~/.trackfw/scripts/trackfw-credential-guard.sh (a global hook can fire from
102
+ // any project's cwd, unlike the project-scope wiring's relative
103
+ // "scripts/trackfw-credential-guard.sh"). Mirrors
104
+ // internal/generators/update.go:harnessCredentialGuardTargetClaude —
105
+ // including deliberately NOT calling generateGlobalCredentialGuardScript here
106
+ // (writing the referenced script file itself is out of this target's scope,
107
+ // same as the Go implementation). Reuses mergeClaudeHookArray (generators/
108
+ // hooks.js) so any pre-existing content in ~/.claude/settings.json (other
109
+ // hooks, user config) is preserved — only the credential-guard entry is
110
+ // added/merged.
111
+ function credentialGuardTargetClaude(homeRoot, { dryRun, installMissing }) {
112
+ const id = 'claude-credential-guard'
113
+ const filePath = path.join(homeRoot, '.claude', 'settings.json')
114
+ const displayPath = tildeify(homeRoot, filePath)
115
+ const scriptPath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
116
+
117
+ let root
118
+ let raw = null
119
+ try {
120
+ if (fs.existsSync(filePath)) {
121
+ raw = fs.readFileSync(filePath, 'utf8')
122
+ root = raw.length ? JSON.parse(raw) : {}
123
+ } else {
124
+ if (!installMissing) return { id, state: 'missing', path: displayPath }
125
+ if (dryRun) return { id, state: 'updated', path: displayPath }
126
+ root = {}
127
+ if (!root.hooks) root.hooks = {}
128
+ root.hooks.PreToolUse = mergeClaudeHookArray(root.hooks.PreToolUse, 'Bash', scriptPath)
129
+ root.hooks.PostToolUse = mergeClaudeHookArray(root.hooks.PostToolUse, 'Bash', scriptPath)
130
+ const desired = JSON.stringify(root, null, 2) + '\n'
131
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
132
+ fs.writeFileSync(filePath, desired, 'utf8')
133
+ return { id, state: 'updated', path: displayPath }
134
+ }
135
+ } catch (e) {
136
+ return { id, state: 'failed', path: displayPath, message: e.message }
137
+ }
138
+
139
+ try {
140
+ if (!root.hooks) root.hooks = {}
141
+ root.hooks.PreToolUse = mergeClaudeHookArray(root.hooks.PreToolUse, 'Bash', scriptPath)
142
+ root.hooks.PostToolUse = mergeClaudeHookArray(root.hooks.PostToolUse, 'Bash', scriptPath)
143
+ const desired = JSON.stringify(root, null, 2) + '\n'
144
+ if (desired === raw) return { id, state: 'skipped', path: displayPath }
145
+ if (dryRun) return { id, state: 'updated', path: displayPath }
146
+ fs.writeFileSync(filePath, desired, 'utf8')
147
+ return { id, state: 'updated', path: displayPath }
148
+ } catch (e) {
149
+ return { id, state: 'failed', path: displayPath, message: e.message }
150
+ }
151
+ }
152
+
153
+ // credentialGuardTargetCodex — evaluates (and, unless --dry-run, applies) the
154
+ // global-scope credential-guard hook wiring for Codex CLI:
155
+ // PreToolUse[matcher:"Bash"]/PostToolUse[matcher:"Bash"] entries in
156
+ // ~/.codex/hooks.json pointing at the ABSOLUTE path of
157
+ // ~/.trackfw/scripts/trackfw-credential-guard.sh — mirrors
158
+ // credentialGuardTargetClaude exactly (same 4-state contract, same idempotent
159
+ // merge via mergeClaudeHookArray, same reason for an absolute path). Mirrors
160
+ // internal/generators/update.go:harnessCredentialGuardTargetCodex.
161
+ //
162
+ // Investigation (ROADMAP-2026-08-06 Wave 2/ML-2B, confirmed 2026-08-06
163
+ // against https://developers.openai.com/codex/hooks): "Hooks are enabled by
164
+ // default. To turn them off in config.toml, set: [features] hooks = false.
165
+ // Use hooks as the canonical feature key. codex_hooks still works as a
166
+ // deprecated alias." No `[features] codex_hooks = true` opt-in is required
167
+ // — the flag exists only to turn hooks OFF and is a deprecated alias for the
168
+ // canonical `hooks` key. https://developers.openai.com/codex/config-advanced
169
+ // (also fetched 2026-08-06) has no conflicting requirement.
170
+ function credentialGuardTargetCodex(homeRoot, { dryRun, installMissing }) {
171
+ const id = 'codex-credential-guard'
172
+ const filePath = path.join(homeRoot, '.codex', 'hooks.json')
173
+ const displayPath = tildeify(homeRoot, filePath)
174
+ const scriptPath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
175
+
176
+ let root
177
+ let raw = null
178
+ try {
179
+ if (fs.existsSync(filePath)) {
180
+ raw = fs.readFileSync(filePath, 'utf8')
181
+ root = raw.length ? JSON.parse(raw) : {}
182
+ } else {
183
+ if (!installMissing) return { id, state: 'missing', path: displayPath }
184
+ if (dryRun) return { id, state: 'updated', path: displayPath }
185
+ root = {}
186
+ if (!root.hooks) root.hooks = {}
187
+ root.hooks.PreToolUse = mergeClaudeHookArray(root.hooks.PreToolUse, 'Bash', scriptPath)
188
+ root.hooks.PostToolUse = mergeClaudeHookArray(root.hooks.PostToolUse, 'Bash', scriptPath)
189
+ const desired = JSON.stringify(root, null, 2) + '\n'
190
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
191
+ fs.writeFileSync(filePath, desired, 'utf8')
192
+ return { id, state: 'updated', path: displayPath }
193
+ }
194
+ } catch (e) {
195
+ return { id, state: 'failed', path: displayPath, message: e.message }
196
+ }
197
+
198
+ try {
199
+ if (!root.hooks) root.hooks = {}
200
+ root.hooks.PreToolUse = mergeClaudeHookArray(root.hooks.PreToolUse, 'Bash', scriptPath)
201
+ root.hooks.PostToolUse = mergeClaudeHookArray(root.hooks.PostToolUse, 'Bash', scriptPath)
202
+ const desired = JSON.stringify(root, null, 2) + '\n'
203
+ if (desired === raw) return { id, state: 'skipped', path: displayPath }
204
+ if (dryRun) return { id, state: 'updated', path: displayPath }
205
+ fs.writeFileSync(filePath, desired, 'utf8')
206
+ return { id, state: 'updated', path: displayPath }
207
+ } catch (e) {
208
+ return { id, state: 'failed', path: displayPath, message: e.message }
209
+ }
210
+ }
211
+
212
+ // credentialGuardTargetGemini — evaluates (and, unless --dry-run, applies)
213
+ // the global-scope credential-guard hook wiring for Gemini CLI:
214
+ // BeforeTool[matcher:"run_shell_command"]/AfterTool[matcher:"run_shell_command"]
215
+ // entries in ~/.gemini/settings.json pointing at the ABSOLUTE path of
216
+ // ~/.trackfw/scripts/trackfw-credential-guard.sh — mirrors
217
+ // credentialGuardTargetClaude/credentialGuardTargetCodex exactly (same
218
+ // 4-state contract, same idempotent merge via mergeClaudeHookArray), only the
219
+ // top-level event key names differ (BeforeTool/AfterTool instead of
220
+ // PreToolUse/PostToolUse, and matcher "run_shell_command" instead of "Bash")
221
+ // since Gemini CLI uses a different hook vocabulary than Claude/Codex (see
222
+ // generators/hooks.js:injectGeminiHooks, confirmed against
223
+ // https://geminicli.com/docs/hooks/reference). Mirrors
224
+ // internal/generators/update.go:harnessCredentialGuardTargetGemini.
225
+ function credentialGuardTargetGemini(homeRoot, { dryRun, installMissing }) {
226
+ const id = 'gemini-credential-guard'
227
+ const filePath = path.join(homeRoot, '.gemini', 'settings.json')
228
+ const displayPath = tildeify(homeRoot, filePath)
229
+ const scriptPath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
230
+
231
+ let root
232
+ let raw = null
233
+ try {
234
+ if (fs.existsSync(filePath)) {
235
+ raw = fs.readFileSync(filePath, 'utf8')
236
+ root = raw.length ? JSON.parse(raw) : {}
237
+ } else {
238
+ if (!installMissing) return { id, state: 'missing', path: displayPath }
239
+ if (dryRun) return { id, state: 'updated', path: displayPath }
240
+ root = {}
241
+ if (!root.hooks) root.hooks = {}
242
+ root.hooks.BeforeTool = mergeClaudeHookArray(root.hooks.BeforeTool, 'run_shell_command', scriptPath)
243
+ root.hooks.AfterTool = mergeClaudeHookArray(root.hooks.AfterTool, 'run_shell_command', scriptPath)
244
+ const desired = JSON.stringify(root, null, 2) + '\n'
245
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
246
+ fs.writeFileSync(filePath, desired, 'utf8')
247
+ return { id, state: 'updated', path: displayPath }
248
+ }
249
+ } catch (e) {
250
+ return { id, state: 'failed', path: displayPath, message: e.message }
251
+ }
252
+
253
+ try {
254
+ if (!root.hooks) root.hooks = {}
255
+ root.hooks.BeforeTool = mergeClaudeHookArray(root.hooks.BeforeTool, 'run_shell_command', scriptPath)
256
+ root.hooks.AfterTool = mergeClaudeHookArray(root.hooks.AfterTool, 'run_shell_command', scriptPath)
257
+ const desired = JSON.stringify(root, null, 2) + '\n'
258
+ if (desired === raw) return { id, state: 'skipped', path: displayPath }
259
+ if (dryRun) return { id, state: 'updated', path: displayPath }
260
+ fs.writeFileSync(filePath, desired, 'utf8')
261
+ return { id, state: 'updated', path: displayPath }
262
+ } catch (e) {
263
+ return { id, state: 'failed', path: displayPath, message: e.message }
264
+ }
265
+ }
266
+
267
+ // credentialGuardTargetCursor — evaluates (and, unless --dry-run, applies)
268
+ // the global-scope credential-guard hook wiring for Cursor:
269
+ // hooks.beforeShellExecution/hooks.afterShellExecution entries in
270
+ // ~/.cursor/hooks.json pointing at the ABSOLUTE path of
271
+ // ~/.trackfw/scripts/trackfw-credential-guard.sh — same 4-state contract and
272
+ // same reason for an absolute path as credentialGuardTargetClaude/Codex/
273
+ // Gemini (a global hook can fire from any project's cwd), but via
274
+ // mergeSimpleCommandArray (generators/hooks.js) instead of
275
+ // mergeClaudeHookArray: Cursor's hooks.json schema
276
+ // (`{"version":1,"hooks":{"<event>":[{"command":"..."}]}}`, confirmed by
277
+ // generators/hooks.js:injectCursorHooks) is structurally different from
278
+ // Claude/Codex/Gemini's — each event array holds flat {"command":"..."}
279
+ // entries, no per-entry "matcher", no nested {"type","hooks":[...]}. Mirrors
280
+ // internal/generators/update.go:harnessCredentialGuardTargetCursor.
281
+ function credentialGuardTargetCursor(homeRoot, { dryRun, installMissing }) {
282
+ const id = 'cursor-credential-guard'
283
+ const filePath = path.join(homeRoot, '.cursor', 'hooks.json')
284
+ const displayPath = tildeify(homeRoot, filePath)
285
+ const scriptPath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
286
+
287
+ let root
288
+ let raw = null
289
+ try {
290
+ if (fs.existsSync(filePath)) {
291
+ raw = fs.readFileSync(filePath, 'utf8')
292
+ root = raw.length ? JSON.parse(raw) : {}
293
+ } else {
294
+ if (!installMissing) return { id, state: 'missing', path: displayPath }
295
+ if (dryRun) return { id, state: 'updated', path: displayPath }
296
+ root = {}
297
+ if (typeof root.version === 'undefined') root.version = 1
298
+ if (!root.hooks) root.hooks = {}
299
+ root.hooks.beforeShellExecution = mergeSimpleCommandArray(root.hooks.beforeShellExecution, scriptPath)
300
+ root.hooks.afterShellExecution = mergeSimpleCommandArray(root.hooks.afterShellExecution, scriptPath)
301
+ const desired = JSON.stringify(root, null, 2) + '\n'
302
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
303
+ fs.writeFileSync(filePath, desired, 'utf8')
304
+ return { id, state: 'updated', path: displayPath }
305
+ }
306
+ } catch (e) {
307
+ return { id, state: 'failed', path: displayPath, message: e.message }
308
+ }
309
+
310
+ try {
311
+ if (typeof root.version === 'undefined') root.version = 1
312
+ if (!root.hooks) root.hooks = {}
313
+ root.hooks.beforeShellExecution = mergeSimpleCommandArray(root.hooks.beforeShellExecution, scriptPath)
314
+ root.hooks.afterShellExecution = mergeSimpleCommandArray(root.hooks.afterShellExecution, scriptPath)
315
+ const desired = JSON.stringify(root, null, 2) + '\n'
316
+ if (desired === raw) return { id, state: 'skipped', path: displayPath }
317
+ if (dryRun) return { id, state: 'updated', path: displayPath }
318
+ fs.writeFileSync(filePath, desired, 'utf8')
319
+ return { id, state: 'updated', path: displayPath }
320
+ } catch (e) {
321
+ return { id, state: 'failed', path: displayPath, message: e.message }
322
+ }
323
+ }
324
+
325
+ // credentialGuardTargetCopilot — evaluates (and, unless --dry-run, applies)
326
+ // the global-scope credential-guard hook wiring for GitHub Copilot:
327
+ // hooks.preToolUse/hooks.postToolUse[matcher:"bash"] entries in
328
+ // ~/.copilot/settings.json pointing at the ABSOLUTE path of
329
+ // ~/.trackfw/scripts/trackfw-credential-guard.sh — same 4-state contract and
330
+ // same reason for an absolute path as credentialGuardTargetClaude/Codex/
331
+ // Gemini/Cursor (a global hook can fire from any project's cwd), but via
332
+ // mergeCopilotHookArray (generators/hooks.js) since Copilot's command-hook
333
+ // entry shape differs from every other tool's (ROADMAP-2026-08-06 ML-2E —
334
+ // see that helper's doc comment and
335
+ // internal/generators/update.go:mergeCredentialGuardCopilotHooks for the
336
+ // full ~/.copilot/settings.json format investigation, including why no
337
+ // top-level "version" key is added: settings.json is Copilot CLI's general
338
+ // user config file, not a dedicated hooks file). Mirrors
339
+ // internal/generators/update.go:harnessCredentialGuardTargetCopilot.
340
+ function credentialGuardTargetCopilot(homeRoot, { dryRun, installMissing }) {
341
+ const id = 'copilot-credential-guard'
342
+ const filePath = path.join(homeRoot, '.copilot', 'settings.json')
343
+ const displayPath = tildeify(homeRoot, filePath)
344
+ const scriptPath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
345
+
346
+ let root
347
+ let raw = null
348
+ try {
349
+ if (fs.existsSync(filePath)) {
350
+ raw = fs.readFileSync(filePath, 'utf8')
351
+ root = raw.length ? JSON.parse(raw) : {}
352
+ } else {
353
+ if (!installMissing) return { id, state: 'missing', path: displayPath }
354
+ if (dryRun) return { id, state: 'updated', path: displayPath }
355
+ root = {}
356
+ if (!root.hooks) root.hooks = {}
357
+ root.hooks.preToolUse = mergeCopilotHookArray(root.hooks.preToolUse, scriptPath)
358
+ root.hooks.postToolUse = mergeCopilotHookArray(root.hooks.postToolUse, scriptPath)
359
+ const desired = JSON.stringify(root, null, 2) + '\n'
360
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
361
+ fs.writeFileSync(filePath, desired, 'utf8')
362
+ return { id, state: 'updated', path: displayPath }
363
+ }
364
+ } catch (e) {
365
+ return { id, state: 'failed', path: displayPath, message: e.message }
366
+ }
367
+
368
+ try {
369
+ if (!root.hooks) root.hooks = {}
370
+ root.hooks.preToolUse = mergeCopilotHookArray(root.hooks.preToolUse, scriptPath)
371
+ root.hooks.postToolUse = mergeCopilotHookArray(root.hooks.postToolUse, scriptPath)
372
+ const desired = JSON.stringify(root, null, 2) + '\n'
373
+ if (desired === raw) return { id, state: 'skipped', path: displayPath }
374
+ if (dryRun) return { id, state: 'updated', path: displayPath }
375
+ fs.writeFileSync(filePath, desired, 'utf8')
376
+ return { id, state: 'updated', path: displayPath }
377
+ } catch (e) {
378
+ return { id, state: 'failed', path: displayPath, message: e.message }
379
+ }
380
+ }
381
+
382
+ // credentialGuardTargetKiro — evaluates (and, unless --dry-run, applies) the
383
+ // global-scope credential-guard hook wiring for Kiro: a DEDICATED file at
384
+ // ~/.kiro/hooks/trackfw-credential-guard.json (unlike claude/codex/gemini/
385
+ // cursor/copilot-credential-guard, which merge into a shared, general
386
+ // settings file — ~/.kiro/hooks/ is a directory of one-file-per-hook,
387
+ // confirmed by generators/hooks.js:injectKiroHooks's own investigation and
388
+ // by kiro.dev/changelog/cli/2-13/: "Hooks placed in ~/.kiro/hooks/ now fire
389
+ // in every workspace automatically ... Workspace-level hooks continue to
390
+ // work alongside global ones"). Same schema as injectKiroHooks (project
391
+ // scope): top-level {"version":"v1","hooks":[...]}, each entry
392
+ // {"name","description","trigger","matcher","action":{"type":"command",
393
+ // "command":<absolute path>}} — but the command here is the ABSOLUTE path
394
+ // of ~/.trackfw/scripts/trackfw-credential-guard.sh (a global hook can fire
395
+ // from any project's cwd), and the two hook names are
396
+ // "trackfw-credential-guard-global-pre"/"-global-post" — deliberately
397
+ // DISTINCT from the project-scope names ("trackfw-credential-guard-pre"/
398
+ // "-post") since this writes an entirely different file and nothing
399
+ // documents whether Kiro deduplicates same-named hooks across scopes/files;
400
+ // ML-3A's future project-scope dedup will match on the script path, not the
401
+ // hook name.
402
+ //
403
+ // Kiro v3 caveat (ROADMAP-2026-08-06 Wave 2/ML-2F, confirmed 2026-08-06
404
+ // against kiro.dev/changelog/cli/2-13/): global hooks are "Available in V3
405
+ // (`kiro-cli --v3`)". `--v3` is a LAUNCH-MODE flag on the same installed
406
+ // binary, not a value any `--version`-style command reports — there is no
407
+ // documented `kiro`/`kiro-cli --version` output format anywhere in the
408
+ // fetched sources, and no persistent installed-version fact to probe from a
409
+ // separate process (trackfw never invokes Kiro itself). This target does
410
+ // NOT attempt a subprocess version probe, and does NOT put the caveat in
411
+ // the JSON `message` field either (pinned contract: `message` is
412
+ // failure-only — see docs/cli-parity.md and
413
+ // internal/commands/update_harness_test.go's
414
+ // TestUpdateHarnessCmd_JSONKeyOrderMatchesCliParityContract). The v3
415
+ // prerequisite is documented here and in docs/cli-parity.md's own "Kiro
416
+ // global-scope wiring (ML-2F)" section instead. Mirrors
417
+ // internal/generators/update.go:harnessCredentialGuardTargetKiro.
418
+ function credentialGuardTargetKiro(homeRoot, { dryRun, installMissing }) {
419
+ const id = 'kiro-credential-guard'
420
+ const filePath = path.join(homeRoot, '.kiro', 'hooks', 'trackfw-credential-guard.json')
421
+ const displayPath = tildeify(homeRoot, filePath)
422
+ const scriptPath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
423
+
424
+ const desired = JSON.stringify(
425
+ {
426
+ version: 'v1',
427
+ hooks: [
428
+ {
429
+ name: 'trackfw-credential-guard-global-pre',
430
+ description: 'Blocks/warns on possible plaintext credential materialization before a shell command executes (global, all projects)',
431
+ trigger: 'PreToolUse',
432
+ matcher: 'shell',
433
+ action: { type: 'command', command: scriptPath },
434
+ },
435
+ {
436
+ name: 'trackfw-credential-guard-global-post',
437
+ description: 'Warns on possible plaintext credential materialization after a shell command executes (global, all projects)',
438
+ trigger: 'PostToolUse',
439
+ matcher: 'shell',
440
+ action: { type: 'command', command: scriptPath },
441
+ },
442
+ ],
443
+ },
444
+ null,
445
+ 2
446
+ ) + '\n'
447
+
448
+ try {
449
+ const exists = fs.existsSync(filePath)
450
+ const actual = exists ? fs.readFileSync(filePath, 'utf8') : null
451
+
452
+ if (!exists && !installMissing) return { id, state: 'missing', path: displayPath }
453
+ if (exists && actual === desired) return { id, state: 'skipped', path: displayPath }
454
+
455
+ if (dryRun) return { id, state: 'updated', path: displayPath }
456
+
457
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
458
+ fs.writeFileSync(filePath, desired, 'utf8')
459
+ return { id, state: 'updated', path: displayPath }
460
+ } catch (e) {
461
+ return { id, state: 'failed', path: displayPath, message: e.message }
462
+ }
463
+ }
464
+
96
465
  // catalogBundleTarget — one target per (tool, kind) pair at global scope.
97
466
  // Uses IntegrationManager.inspect (read-only) to classify every catalog
98
467
  // item under that pair, then only calls manager.update() for the subset
@@ -136,8 +505,19 @@ function catalogBundleTarget(toolId, kind, homeRoot, identityConfig, { dryRun, i
136
505
  }
137
506
  }
138
507
 
139
- const HARNESS_TARGET_IDS = ['claude-skill']
508
+ // HARNESS_TARGET_IDS — mirrors internal/generators/update.go:HarnessTargetIDs.
509
+ // "codex-credential-guard"/"gemini-credential-guard" are each inserted
510
+ // immediately BEFORE their tool's "-agents"/"-skills" pair (same relative
511
+ // position as claude-credential-guard, which precedes claude-agents/
512
+ // claude-skills) — see buildHarnessTargetIDs's comment in update.go for the
513
+ // full rationale.
514
+ const HARNESS_TARGET_IDS = ['claude-skill', 'claude-credential-guard']
140
515
  for (const target of catalog.targets) {
516
+ if (target.id === 'codex') HARNESS_TARGET_IDS.push('codex-credential-guard')
517
+ if (target.id === 'gemini') HARNESS_TARGET_IDS.push('gemini-credential-guard')
518
+ if (target.id === 'cursor') HARNESS_TARGET_IDS.push('cursor-credential-guard')
519
+ if (target.id === 'copilot') HARNESS_TARGET_IDS.push('copilot-credential-guard')
520
+ if (target.id === 'kiro') HARNESS_TARGET_IDS.push('kiro-credential-guard')
141
521
  HARNESS_TARGET_IDS.push(`${target.id}-agents`, `${target.id}-skills`)
142
522
  }
143
523
 
@@ -150,7 +530,23 @@ function buildHarnessTargets(homeRoot, identityConfig, { dryRun, installMissing
150
530
  const include = (id) => !wanted || wanted.includes(id)
151
531
  const targets = []
152
532
  if (include('claude-skill')) targets.push(claudeSkillTarget(homeRoot, { dryRun, installMissing }))
533
+ if (include('claude-credential-guard')) targets.push(credentialGuardTargetClaude(homeRoot, { dryRun, installMissing }))
153
534
  for (const target of catalog.targets) {
535
+ if (target.id === 'codex' && include('codex-credential-guard')) {
536
+ targets.push(credentialGuardTargetCodex(homeRoot, { dryRun, installMissing }))
537
+ }
538
+ if (target.id === 'gemini' && include('gemini-credential-guard')) {
539
+ targets.push(credentialGuardTargetGemini(homeRoot, { dryRun, installMissing }))
540
+ }
541
+ if (target.id === 'cursor' && include('cursor-credential-guard')) {
542
+ targets.push(credentialGuardTargetCursor(homeRoot, { dryRun, installMissing }))
543
+ }
544
+ if (target.id === 'copilot' && include('copilot-credential-guard')) {
545
+ targets.push(credentialGuardTargetCopilot(homeRoot, { dryRun, installMissing }))
546
+ }
547
+ if (target.id === 'kiro' && include('kiro-credential-guard')) {
548
+ targets.push(credentialGuardTargetKiro(homeRoot, { dryRun, installMissing }))
549
+ }
154
550
  const agentsId = `${target.id}-agents`
155
551
  const skillsId = `${target.id}-skills`
156
552
  if (include(agentsId)) targets.push(catalogBundleTarget(target.id, 'agents', homeRoot, identityConfig, { dryRun, installMissing }))
@@ -205,4 +601,15 @@ function run(options) {
205
601
  if (doc.summary.failed > 0) process.exitCode = 1
206
602
  }
207
603
 
208
- module.exports = { run, HARNESS_TARGET_IDS, buildHarnessTargets, claudeSkillContent }
604
+ module.exports = {
605
+ run,
606
+ HARNESS_TARGET_IDS,
607
+ buildHarnessTargets,
608
+ claudeSkillContent,
609
+ credentialGuardTargetClaude,
610
+ credentialGuardTargetCodex,
611
+ credentialGuardTargetGemini,
612
+ credentialGuardTargetCursor,
613
+ credentialGuardTargetCopilot,
614
+ credentialGuardTargetKiro,
615
+ }
@@ -130,7 +130,7 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
130
130
 
131
131
  if (include('agent-hooks')) targets.push(runFileTarget({
132
132
  id: 'agent-hooks',
133
- path: '.claude/settings.json, .codex/hooks.json, .gemini/settings.json, .kiro/hooks/trackfw-attention.json, .github/hooks/trackfw-attention.json, .cursor/hooks.json, scripts/trackfw-attention-*.sh',
133
+ path: '.claude/settings.json, .codex/hooks.json, .gemini/settings.json, .kiro/hooks/trackfw-attention.json, .github/hooks/trackfw-attention.json, .cursor/hooks.json, scripts/trackfw-attention-*.sh, scripts/trackfw-credential-guard.sh',
134
134
  root: cwd,
135
135
  relPaths: [
136
136
  '.claude/settings.json',
@@ -141,10 +141,12 @@ function buildProjectTargets(cwd, cfg, identityConfig, { dryRun, installMissing
141
141
  '.cursor/hooks.json',
142
142
  'scripts/trackfw-attention-signal.sh',
143
143
  'scripts/trackfw-attention-cleanup.sh',
144
+ 'scripts/trackfw-credential-guard.sh',
144
145
  ],
145
146
  apply: (root) => {
146
147
  hooksGen.injectHooksDetected(root);
147
148
  hooksGen.generateAttentionScripts(cfg, root);
149
+ hooksGen.generateCredentialGuardScript(root);
148
150
  },
149
151
  dryRun,
150
152
  installMissing,
@@ -57,6 +57,11 @@ function defaults() {
57
57
  jiraToken: '',
58
58
  jiraProject: '',
59
59
  },
60
+ // credential_guard field — see ADR-2026-08-05-hook-de-guarda-contra-materializacao-de-
61
+ // credenciais-reais-por-subagentes.md.
62
+ credentialGuard: {
63
+ mode: 'warn',
64
+ },
60
65
  rules: {
61
66
  wip_has_req: 'error',
62
67
  wip_acceptance: 'error',
@@ -260,6 +265,15 @@ function parse(content, cfg) {
260
265
  if (stringVal(m, 'jira_token') !== undefined) cfg.sync.jiraToken = m.jira_token;
261
266
  if (stringVal(m, 'jira_project') !== undefined) cfg.sync.jiraProject = m.jira_project;
262
267
 
268
+ // credential_guard field — nested mapping, same shape as link_fields above. An unrecognized
269
+ // mode value (or an absent/malformed credential_guard block) falls back to the safe default
270
+ // ("warn") silently, matching how every other unrecognized-shape field in this parser behaves
271
+ // (e.g. roadmap_namespacing, forge) — no fatal path, no stderr message, for one enum key.
272
+ if (m.credential_guard !== undefined && typeof m.credential_guard === 'object' && !Array.isArray(m.credential_guard)) {
273
+ const mode = stringVal(m.credential_guard, 'mode');
274
+ if (mode === 'warn' || mode === 'block') cfg.credentialGuard.mode = mode;
275
+ }
276
+
263
277
  return false;
264
278
  }
265
279
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
+ const os = require('os')
5
6
 
6
7
  // ---------------------------------------------------------------------------
7
8
  // Helpers
@@ -28,6 +29,37 @@ function hasEntry(arr, field, value) {
28
29
  return Array.isArray(arr) && arr.some(e => e && e[field] === value)
29
30
  }
30
31
 
32
+ /**
33
+ * Merge helper para arrays de hooks "simples" tipo Cursor
34
+ * (hooks.beforeShellExecution/afterShellExecution): cada entry é um objeto
35
+ * plano {"command": "..."} — sem matcher, sem {type, hooks:[...]} aninhado
36
+ * como Claude/Codex/Gemini. Mirrors internal/generators/agentfiles.go:
37
+ * mergeSimpleCommandArray.
38
+ */
39
+ function mergeSimpleCommandArray(existing, command) {
40
+ const arr = Array.isArray(existing) ? existing.slice() : []
41
+ if (hasEntry(arr, 'command', command)) return arr
42
+ arr.push({ command })
43
+ return arr
44
+ }
45
+
46
+ /**
47
+ * Merge helper para arrays de hooks tipo GitHub Copilot
48
+ * (hooks.preToolUse/postToolUse): cada entry é
49
+ * {"type":"command","matcher":"bash","bash":"...","cwd":".","timeoutSec":10}
50
+ * — o campo de match é "bash" (não "command", como no shape "simples" do
51
+ * Cursor), então mergeSimpleCommandArray não serve aqui.
52
+ * Mirrors internal/generators/update.go:mergeCredentialGuardCopilotHooks
53
+ * (ROADMAP-2026-08-06 Wave 2/ML-2E — see that Go function's doc comment for
54
+ * the full ~/.copilot/settings.json format investigation).
55
+ */
56
+ function mergeCopilotHookArray(existing, scriptPath) {
57
+ const arr = Array.isArray(existing) ? existing.slice() : []
58
+ if (hasEntry(arr, 'bash', scriptPath)) return arr
59
+ arr.push({ type: 'command', matcher: 'bash', bash: scriptPath, cwd: '.', timeoutSec: 10 })
60
+ return arr
61
+ }
62
+
31
63
  /** Merge helper para arrays de hooks tipo Claude / Codex / Gemini */
32
64
  function mergeClaudeHookArray(existing, matcher, command) {
33
65
  const arr = Array.isArray(existing) ? existing : []
@@ -113,8 +145,290 @@ rm -f "$ROADMAP_DIR/.trackfw-attention.json"
113
145
  exit 0
114
146
  `
115
147
 
148
+ // CG_HEADER/CG_PROJECT_GUARD/CG_DETECTION_CORE/CG_PROJECT_TAIL/CG_GLOBAL_TAIL compõem
149
+ // CREDENTIAL_GUARD_SCRIPT (escopo de projeto) e GLOBAL_CREDENTIAL_GUARD_SCRIPT (escopo global,
150
+ // ~/.trackfw/scripts/, instalado via `trackfw update harness`) sem duplicar a lógica de detecção
151
+ // JWT/AWS-key em dois lugares — espelha a mesma decomposição em internal/generators/scaffold.go
152
+ // (credentialGuardHeader/credentialGuardDetectionCore/...).
153
+
154
+ const CG_HEADER = `#!/usr/bin/env bash
155
+ # trackfw credential guard — PreToolUse/PostToolUse hook
156
+ set -euo pipefail
157
+
158
+ INPUT=$(cat)
159
+
160
+ `
161
+
162
+ const CG_PROJECT_GUARD = `# Script is intentionally a no-op when executed outside the project root
163
+ [ -f "trackfw.yaml" ] || exit 0
164
+
165
+ `
166
+
167
+ const CG_DETECTION_CORE = `JWT_PATTERN='eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+'
168
+ AWS_KEY_PATTERN='AKIA[0-9A-Z]{16}'
169
+
170
+ MATCH=""
171
+ if printf '%s' "$INPUT" | grep -qE "$JWT_PATTERN"; then
172
+ MATCH="JWT"
173
+ elif printf '%s' "$INPUT" | grep -qE "$AWS_KEY_PATTERN"; then
174
+ MATCH="AWS access key"
175
+ fi
176
+
177
+ [ -n "$MATCH" ] || exit 0
178
+
179
+ # The raw payload is JSON: any double quote inside the underlying tool_input.command is
180
+ # escaped as \\" -- unescape those before scanning for redirect targets, or a quoted target
181
+ # like "$TMPFILE" is seen as starting with a literal backslash instead of a variable
182
+ # reference.
183
+ RAW=$(printf '%s' "$INPUT" | sed 's/\\\\"/"/g')
184
+
185
+ # Ignore matches that are only ever written to an ephemeral destination
186
+ # (mktemp-derived path or /dev/null). A match with no redirect at all
187
+ # (printed to stdout, e.g.) or redirected to a plain file path still
188
+ # alerts -- that is the incident this hook guards against.
189
+ is_ephemeral_target() {
190
+ local target
191
+ target=$(printf '%s' "$1" | tr -d "\\"'" | sed -E 's/[},]+$//')
192
+ case "$target" in
193
+ /dev/null) return 0 ;;
194
+ *mktemp*) return 0 ;;
195
+ esac
196
+ if printf '%s' "$target" | grep -qE '^\\$\\{?[A-Za-z_][A-Za-z0-9_]*\\}?$'; then
197
+ local varname pattern
198
+ varname=$(printf '%s' "$target" | sed -E 's/^\\$\\{?([A-Za-z_][A-Za-z0-9_]*)\\}?$/\\1/')
199
+ pattern="*\${varname}="'$(mktemp'"*"
200
+ case "$RAW" in
201
+ $pattern) return 0 ;;
202
+ esac
203
+ fi
204
+ return 1
205
+ }
206
+
207
+ REDIRECTS=$(printf '%s' "$RAW" | grep -oE '[0-9]?>>?[[:space:]]*[^[:space:]|&;,:]+' || true)
208
+
209
+ HAS_REDIRECT=0
210
+ EXEMPT=1
211
+ if [ -n "$REDIRECTS" ]; then
212
+ while IFS= read -r line; do
213
+ if [ -z "$line" ]; then
214
+ continue
215
+ fi
216
+ HAS_REDIRECT=1
217
+ target=$(printf '%s' "$line" | sed -E 's/^[0-9]?>>?[[:space:]]*//')
218
+ if ! is_ephemeral_target "$target"; then
219
+ EXEMPT=0
220
+ fi
221
+ done <<< "$REDIRECTS"
222
+ fi
223
+
224
+ if [ "$HAS_REDIRECT" -eq 1 ] && [ "$EXEMPT" -eq 1 ]; then
225
+ exit 0
226
+ fi
227
+
228
+ `
229
+
230
+ const CG_PROJECT_TAIL = `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
+ case "$MODE" in
232
+ warn|block) ;;
233
+ *) MODE="warn" ;;
234
+ esac
235
+
236
+ if [ "$MODE" = "block" ]; then
237
+ echo "trackfw-credential-guard: blocked - possible $MATCH detected in tool payload." >&2
238
+ exit 2
239
+ fi
240
+
241
+ echo "trackfw-credential-guard: warning - possible $MATCH detected in tool payload." >&2
242
+
243
+ ROADMAP_DIR=$(grep '^roadmap_dir:' trackfw.yaml 2>/dev/null | head -1 | sed 's/^roadmap_dir:[[:space:]]*//; s/[[:space:]]*#.*$//' | tr -d '"' | tr -d "'" || true)
244
+ ROADMAP_DIR=\${ROADMAP_DIR:-docs/roadmaps}
245
+
246
+ case "$ROADMAP_DIR" in
247
+ /*|../*|*/../*|*/..|..) ROADMAP_DIR="docs/roadmaps" ;;
248
+ esac
249
+
250
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
251
+ MSG="Possible $MATCH detected in tool payload - review before materializing credentials in plain text."
252
+ MSG_ESC=$(echo "$MSG" | tr -d '\\000-\\037' | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g')
253
+
254
+ mkdir -p "$ROADMAP_DIR"
255
+ printf '{"tool":"credential-guard","message":"%s","level":"action_required","timestamp":"%s"}\\n' \\
256
+ "$MSG_ESC" \\
257
+ "$TIMESTAMP" > "$ROADMAP_DIR/.trackfw-credential-guard.json"
258
+
259
+ exit 0
260
+ `
261
+
262
+ // Escopo global: modo sempre "warn" (ver ADR-2026-08-06/ROADMAP-2026-08-06 Wave 1 — decisão "b",
263
+ // sem ~/.trackfw/config.yaml) e ROADMAP_DIR fixo em "docs/roadmaps" relativo ao cwd, só grava o
264
+ // attention signal se esse diretório já existir (não cria docs/roadmaps num projeto qualquer).
265
+ const CG_GLOBAL_TAIL = `MODE="warn"
266
+
267
+ echo "trackfw-credential-guard: warning - possible $MATCH detected in tool payload." >&2
268
+
269
+ ROADMAP_DIR="docs/roadmaps"
270
+ if [ ! -d "$ROADMAP_DIR" ]; then
271
+ exit 0
272
+ fi
273
+
274
+ TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
275
+ MSG="Possible $MATCH detected in tool payload - review before materializing credentials in plain text."
276
+ MSG_ESC=$(echo "$MSG" | tr -d '\\000-\\037' | sed 's/\\\\/\\\\\\\\/g; s/"/\\\\"/g')
277
+
278
+ mkdir -p "$ROADMAP_DIR"
279
+ printf '{"tool":"credential-guard","message":"%s","level":"action_required","timestamp":"%s"}\\n' \\
280
+ "$MSG_ESC" \\
281
+ "$TIMESTAMP" > "$ROADMAP_DIR/.trackfw-credential-guard.json"
282
+
283
+ exit 0
284
+ `
285
+
286
+ const CREDENTIAL_GUARD_SCRIPT = CG_HEADER + CG_PROJECT_GUARD + CG_DETECTION_CORE + CG_PROJECT_TAIL
287
+
288
+ const GLOBAL_CREDENTIAL_GUARD_SCRIPT = CG_HEADER + CG_DETECTION_CORE + CG_GLOBAL_TAIL
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // generateCredentialGuardScript — writes scripts/trackfw-credential-guard.sh
292
+ // ---------------------------------------------------------------------------
293
+ // ML-1A only: creates the script. It is NOT wired into any hooks.json/settings.json
294
+ // here -- that is Wave 2's scope (see ROADMAP-2026-08-05-hooks-de-guarda-contra-
295
+ // materializacao-de-credenciais-reais-por-subagentes.md).
296
+ function generateCredentialGuardScript(cwd) {
297
+ const root = cwd || process.cwd()
298
+ const scriptsDir = path.join(root, 'scripts')
299
+ fs.mkdirSync(scriptsDir, { recursive: true })
300
+
301
+ const scriptPath = path.join(scriptsDir, 'trackfw-credential-guard.sh')
302
+ fs.writeFileSync(scriptPath, CREDENTIAL_GUARD_SCRIPT, { encoding: 'utf8', mode: 0o755 })
303
+
304
+ console.log(' ✓ scripts/trackfw-credential-guard.sh')
305
+ }
306
+
307
+ // ---------------------------------------------------------------------------
308
+ // generateGlobalCredentialGuardScript — writes <home>/.trackfw/scripts/trackfw-credential-guard.sh
309
+ // ---------------------------------------------------------------------------
310
+ // Destinado a ser referenciado por hooks globais de CLI, instalados via `trackfw update harness`
311
+ // (ver ROADMAP-2026-08-06, Wave 2) -- não é chamado por `trackfw init`/`trackfw update` (escopo de
312
+ // projeto), que continuam usando generateCredentialGuardScript.
313
+ function generateGlobalCredentialGuardScript(home) {
314
+ if (!home) {
315
+ throw new Error('home directory vazio')
316
+ }
317
+ const scriptsDir = path.join(home, '.trackfw', 'scripts')
318
+ fs.mkdirSync(scriptsDir, { recursive: true })
319
+
320
+ const scriptPath = path.join(scriptsDir, 'trackfw-credential-guard.sh')
321
+ fs.writeFileSync(scriptPath, GLOBAL_CREDENTIAL_GUARD_SCRIPT, { encoding: 'utf8', mode: 0o755 })
322
+
323
+ console.log(' ✓ .trackfw/scripts/trackfw-credential-guard.sh')
324
+ }
325
+
116
326
  const SIGNAL_CMD = 'scripts/trackfw-attention-signal.sh'
117
327
  const CLEANUP_CMD = 'scripts/trackfw-attention-cleanup.sh'
328
+ const GUARD_CMD = 'scripts/trackfw-credential-guard.sh'
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // Global credential-guard dedup (ROADMAP-2026-08-06 Wave 3/ML-3A)
332
+ // ---------------------------------------------------------------------------
333
+ // injectClaudeHooks/injectCodexHooks/injectGeminiHooks/injectCursorHooks/
334
+ // injectCopilotHooks/injectKiroHooks each check, read-only, whether the user
335
+ // already has the global-scope credential-guard wiring installed for that
336
+ // CLI (via `trackfw update harness --targets <tool>-credential-guard`,
337
+ // npm/src/commands/update-harness.js) before adding the project-scope
338
+ // credential-guard entry. If the global entry is already present, the
339
+ // project-scope entry is skipped entirely — attention-signal/cleanup are
340
+ // unaffected (inherently project-scoped, ADR-2026-08-06 Decision #4).
341
+ //
342
+ // Fail-open is mandatory: any failure to resolve $HOME, read the global
343
+ // file, or parse its JSON is treated as "not installed globally" -- this
344
+ // section never writes to the global file (read-only by construction).
345
+
346
+ /** Mirrors Go's globalCredentialGuardScriptPath. */
347
+ function globalCredentialGuardScriptPath() {
348
+ const home = os.homedir()
349
+ if (!home) return null
350
+ return path.join(home, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
351
+ }
352
+
353
+ /** Reads+parses JSON at $HOME/<...relParts>; returns null on any failure (fail-open). */
354
+ function readGlobalHookJSON(...relParts) {
355
+ const home = os.homedir()
356
+ if (!home) return null
357
+ try {
358
+ const raw = fs.readFileSync(path.join(home, ...relParts), 'utf8')
359
+ return JSON.parse(raw)
360
+ } catch (_) {
361
+ return null
362
+ }
363
+ }
364
+
365
+ /** Read-only counterpart of mergeClaudeHookArray. */
366
+ function hookArrayHasCommand(existing, matcher, command) {
367
+ const arr = Array.isArray(existing) ? existing : []
368
+ for (const item of arr) {
369
+ if (!item || item.matcher !== matcher) continue
370
+ const inner = Array.isArray(item.hooks) ? item.hooks : []
371
+ if (inner.some(h => h && h.command === command)) return true
372
+ }
373
+ return false
374
+ }
375
+
376
+ function globalCredentialGuardInstalledClaude() {
377
+ const scriptPath = globalCredentialGuardScriptPath()
378
+ if (!scriptPath) return false
379
+ const root = readGlobalHookJSON('.claude', 'settings.json')
380
+ if (!root || !root.hooks) return false
381
+ return hookArrayHasCommand(root.hooks.PreToolUse, 'Bash', scriptPath)
382
+ }
383
+
384
+ function globalCredentialGuardInstalledCodex() {
385
+ const scriptPath = globalCredentialGuardScriptPath()
386
+ if (!scriptPath) return false
387
+ const root = readGlobalHookJSON('.codex', 'hooks.json')
388
+ if (!root || !root.hooks) return false
389
+ return hookArrayHasCommand(root.hooks.PreToolUse, 'Bash', scriptPath)
390
+ }
391
+
392
+ function globalCredentialGuardInstalledGemini() {
393
+ const scriptPath = globalCredentialGuardScriptPath()
394
+ if (!scriptPath) return false
395
+ const root = readGlobalHookJSON('.gemini', 'settings.json')
396
+ if (!root || !root.hooks) return false
397
+ return hookArrayHasCommand(root.hooks.BeforeTool, 'run_shell_command', scriptPath)
398
+ }
399
+
400
+ function globalCredentialGuardInstalledCursor() {
401
+ const scriptPath = globalCredentialGuardScriptPath()
402
+ if (!scriptPath) return false
403
+ const root = readGlobalHookJSON('.cursor', 'hooks.json')
404
+ if (!root || !root.hooks) return false
405
+ return hasEntry(root.hooks.beforeShellExecution, 'command', scriptPath)
406
+ }
407
+
408
+ function globalCredentialGuardInstalledCopilot() {
409
+ const scriptPath = globalCredentialGuardScriptPath()
410
+ if (!scriptPath) return false
411
+ const root = readGlobalHookJSON('.copilot', 'settings.json')
412
+ if (!root || !root.hooks) return false
413
+ return hasEntry(root.hooks.preToolUse, 'bash', scriptPath)
414
+ }
415
+
416
+ /**
417
+ * ~/.kiro/hooks/trackfw-credential-guard.json is 100% dedicated to the
418
+ * global credential-guard wiring (overwritten wholesale, never merged), so
419
+ * presence + non-empty content is sufficient — matches the roadmap's
420
+ * explicit instruction for Kiro.
421
+ */
422
+ function globalCredentialGuardInstalledKiro() {
423
+ const home = os.homedir()
424
+ if (!home) return false
425
+ try {
426
+ const stat = fs.statSync(path.join(home, '.kiro', 'hooks', 'trackfw-credential-guard.json'))
427
+ return stat.size > 0
428
+ } catch (_) {
429
+ return false
430
+ }
431
+ }
118
432
 
119
433
  // ---------------------------------------------------------------------------
120
434
  // generateAttentionScripts — writes the two shell scripts to scripts/
@@ -145,13 +459,30 @@ function injectClaudeHooks(cwd) {
145
459
 
146
460
  if (!data.hooks) data.hooks = {}
147
461
  data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'AskUserQuestion', SIGNAL_CMD)
462
+ // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A): skip project-scope
463
+ // credential-guard when the global one is already installed.
464
+ if (!globalCredentialGuardInstalledClaude()) {
465
+ data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GUARD_CMD)
466
+ }
148
467
  data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'AskUserQuestion', CLEANUP_CMD)
468
+ if (!globalCredentialGuardInstalledClaude()) {
469
+ data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Bash', GUARD_CMD)
470
+ }
149
471
 
150
472
  writeJSON(filePath, data)
151
473
  }
152
474
 
153
475
  // ---------------------------------------------------------------------------
154
476
  // Codex — .codex/hooks.json
477
+ //
478
+ // Two independent hook events: PermissionRequest (matcher ".*") for the existing
479
+ // attention-signal -- only fires when Codex is about to prompt for approval, not
480
+ // for every command -- and PreToolUse/PostToolUse (matcher "Bash") for
481
+ // credential-guard, which fires for every Bash tool call regardless of approval.
482
+ // Confirmed against https://developers.openai.com/codex/hooks (2026-08-05): hooks
483
+ // are enabled by default (no `[features] hooks = true`/`codex_hooks` opt-in
484
+ // needed -- that flag exists only to turn hooks OFF), and PreToolUse blocking
485
+ // uses exit code 2 + stderr (matching trackfw-credential-guard.sh's "block" mode).
155
486
  // ---------------------------------------------------------------------------
156
487
 
157
488
  function injectCodexHooks(cwd) {
@@ -160,13 +491,38 @@ function injectCodexHooks(cwd) {
160
491
 
161
492
  if (!data.hooks) data.hooks = {}
162
493
  data.hooks.PermissionRequest = mergeClaudeHookArray(data.hooks.PermissionRequest, '.*', SIGNAL_CMD)
494
+ // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A): skip project-scope
495
+ // credential-guard when the global one is already installed.
496
+ const skipCodexCG = globalCredentialGuardInstalledCodex()
497
+ if (!skipCodexCG) {
498
+ data.hooks.PreToolUse = mergeClaudeHookArray(data.hooks.PreToolUse, 'Bash', GUARD_CMD)
499
+ }
163
500
  data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, '.*', CLEANUP_CMD)
501
+ if (!skipCodexCG) {
502
+ data.hooks.PostToolUse = mergeClaudeHookArray(data.hooks.PostToolUse, 'Bash', GUARD_CMD)
503
+ }
164
504
 
165
505
  writeJSON(filePath, data)
166
506
  }
167
507
 
168
508
  // ---------------------------------------------------------------------------
169
509
  // Gemini — .gemini/settings.json
510
+ //
511
+ // Three independent hook events: Notification (matcher "ToolPermission") for the
512
+ // existing attention-signal -- only fires when Gemini CLI is about to prompt for
513
+ // permission, not for every tool call -- and BeforeTool/AfterTool (matcher
514
+ // "run_shell_command") for credential-guard, which fires for every shell tool call
515
+ // regardless of whether a permission prompt is needed. Confirmed against
516
+ // https://geminicli.com/docs/hooks/reference (retrieved 2026-08-05): BeforeTool
517
+ // "Fires before a tool is invoked. Used for argument validation, security checks,
518
+ // and parameter rewriting" and supports "Exit Code 2 (Block Tool): Prevents
519
+ // execution. Uses stderr as the reason" -- matching trackfw-credential-guard.sh's
520
+ // existing "block" mode. The shell tool's canonical name is "run_shell_command"
521
+ // (doc: "you can match any built-in tool (for example, read_file,
522
+ // run_shell_command)"); matcher is a regex evaluated against tool_name. AfterTool
523
+ // (matcher "*") is the pre-existing attention-cleanup wiring, unrelated to the new
524
+ // credential-guard entry added as a separate array entry (different matcher) in the
525
+ // same event.
170
526
  // ---------------------------------------------------------------------------
171
527
 
172
528
  function injectGeminiHooks(cwd) {
@@ -175,75 +531,232 @@ function injectGeminiHooks(cwd) {
175
531
 
176
532
  if (!data.hooks) data.hooks = {}
177
533
  data.hooks.Notification = mergeClaudeHookArray(data.hooks.Notification, 'ToolPermission', SIGNAL_CMD)
534
+ // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A): skip project-scope
535
+ // credential-guard when the global one is already installed.
536
+ const skipGeminiCG = globalCredentialGuardInstalledGemini()
537
+ if (!skipGeminiCG) {
538
+ data.hooks.BeforeTool = mergeClaudeHookArray(data.hooks.BeforeTool, 'run_shell_command', GUARD_CMD)
539
+ }
178
540
  data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, '*', CLEANUP_CMD)
541
+ if (!skipGeminiCG) {
542
+ data.hooks.AfterTool = mergeClaudeHookArray(data.hooks.AfterTool, 'run_shell_command', GUARD_CMD)
543
+ }
179
544
 
180
545
  writeJSON(filePath, data)
181
546
  }
182
547
 
183
548
  // ---------------------------------------------------------------------------
184
549
  // Kiro — .kiro/hooks/trackfw-attention.json (dedicated file, safe overwrite)
550
+ //
551
+ // Format confirmed against https://kiro.dev/docs/hooks/ , https://kiro.dev/docs/hooks/types and
552
+ // https://kiro.dev/docs/hooks/actions/ (retrieved 2026-08-05). Top level is {"version": "v1", "hooks":
553
+ // [...]} ("version" is the string "v1"), each entry {"name", "description"?, "trigger", "matcher"?,
554
+ // "action", ...}. The field is "trigger" (NOT "event" as previously emitted here and in the Go/Python
555
+ // siblings -- "event" does not exist in the documented schema). "matcher" is a plain regex string
556
+ // matched against tool name for PreToolUse/PostToolUse (NOT an object like {tool_name: ".*"} as
557
+ // previously emitted) -- "*" is the documented wildcard for "all tools"; ".*" is not a documented
558
+ // matcher value. PreToolUse ("Before a tool is about to execute", Can block: Yes) is confirmed distinct
559
+ // from PostFileSave/file-save events, resolving the ADR's open question about Kiro intercepting shell
560
+ // commands pre-execution. Blocking contract: any non-zero exit from a PreToolUse command hook blocks
561
+ // the tool invocation (stricter than the exit-code-2-specific contract of Claude Code/Codex/Gemini);
562
+ // trackfw-credential-guard.sh only ever exits 0 or 2 on its normal-operation paths (ML-1A), so this is
563
+ // safe. Shell tool matcher uses the documented alias "shell" ("all built-in shell command-related
564
+ // tools"), broader than the single canonical tool id "execute_bash". This file is fully
565
+ // generated/overwritten by trackfw (not merged with user content), so the legacy attention-signal/
566
+ // cleanup entries are realigned to the correct schema here too rather than left in the old, never-valid
567
+ // shape (same situation as the GitHub Copilot fix in ML-2D).
185
568
  // ---------------------------------------------------------------------------
186
569
 
187
570
  function injectKiroHooks(cwd) {
188
571
  const filePath = path.join(cwd, '.kiro', 'hooks', 'trackfw-attention.json')
189
- const data = {
190
- hooks: [
572
+ const hooks = [
573
+ {
574
+ name: 'trackfw-attention-signal',
575
+ description: 'Signals trackfw board when agent executes a tool',
576
+ trigger: 'PreToolUse',
577
+ matcher: '*',
578
+ action: { type: 'command', command: SIGNAL_CMD },
579
+ },
580
+ {
581
+ name: 'trackfw-attention-cleanup',
582
+ description: 'Clears trackfw board attention after tool completes',
583
+ trigger: 'PostToolUse',
584
+ matcher: '*',
585
+ action: { type: 'command', command: CLEANUP_CMD },
586
+ },
587
+ ]
588
+
589
+ // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A): skip project-scope
590
+ // credential-guard entries when the global one is already installed.
591
+ if (!globalCredentialGuardInstalledKiro()) {
592
+ hooks.push(
191
593
  {
192
- name: 'trackfw-attention-signal',
193
- description: 'Signals trackfw board when agent executes a tool',
194
- event: 'PreToolUse',
195
- matcher: { tool_name: '.*' },
196
- action: { type: 'command', command: SIGNAL_CMD },
594
+ name: 'trackfw-credential-guard-pre',
595
+ description: 'Blocks/warns on possible plaintext credential materialization before a shell command executes',
596
+ trigger: 'PreToolUse',
597
+ matcher: 'shell',
598
+ action: { type: 'command', command: GUARD_CMD },
197
599
  },
198
600
  {
199
- name: 'trackfw-attention-cleanup',
200
- description: 'Clears trackfw board attention after tool completes',
201
- event: 'PostToolUse',
202
- matcher: { tool_name: '.*' },
203
- action: { type: 'command', command: CLEANUP_CMD },
601
+ name: 'trackfw-credential-guard-post',
602
+ description: 'Warns on possible plaintext credential materialization after a shell command executes',
603
+ trigger: 'PostToolUse',
604
+ matcher: 'shell',
605
+ action: { type: 'command', command: GUARD_CMD },
204
606
  },
205
- ],
607
+ )
206
608
  }
609
+
610
+ const data = { version: 'v1', hooks }
207
611
  writeJSON(filePath, data)
208
612
  }
209
613
 
210
614
  // ---------------------------------------------------------------------------
211
615
  // Copilot — .github/hooks/trackfw-attention.json (dedicated file, safe overwrite)
616
+ //
617
+ // Format confirmed against https://docs.github.com/en/copilot/reference/hooks-reference (retrieved
618
+ // 2026-08-05): repository-level hook files live at .github/hooks/*.json, using the schema
619
+ // {"version": 1, "hooks": {"<event>": [<command entry>, ...]}}, where a command entry is
620
+ // {"type": "command", "bash": "...", "cwd": "...", "timeoutSec": N}. This is the format
621
+ // `inject_copilot_hooks` (Python) already used; the {"hooks": [{"event", "run"}]} shape this function
622
+ // previously emitted does not match any format documented by GitHub -- this ML aligns Go/Node to
623
+ // Python (which was correct) rather than the other way around.
624
+ //
625
+ // Matcher: the doc's matcher-filtering table lists `preToolUse -> toolName` and `postToolUse ->
626
+ // toolName` (a regex, anchored `^(?:PATTERN)$`), and shows a worked `"matcher"` field inline on a
627
+ // postToolUse command entry. With camelCase event names (preToolUse/postToolUse, used here), toolName
628
+ // carries the runtime tool name, and the shell tool's runtime name is "bash" (lowercase) -- distinct
629
+ // from PascalCase events, which report the Claude-mapped name "Bash". trackfw-credential-guard.sh
630
+ // scans the raw JSON payload for JWT/AWS-key patterns regardless of field names (ML-1A), so it works
631
+ // under either payload shape; the matcher below is a scope-narrowing optimization only.
632
+ //
633
+ // Concurrency: "If multiple hooks of the same type are configured, they execute in order" (same
634
+ // section) -- Copilot hooks run serially, in configured order, for the same event, unlike Codex's
635
+ // confirmed-concurrent or Gemini's undocumented cross-group model. The ML-1A fix (credential-guard's
636
+ // "warn" mode writes to its own dedicated $ROADMAP_DIR/.trackfw-credential-guard.json, never touching
637
+ // the shared .trackfw-attention.json that trackfw-attention-cleanup.sh deletes) makes ordering moot
638
+ // regardless.
212
639
  // ---------------------------------------------------------------------------
213
640
 
214
641
  function injectCopilotHooks(cwd) {
215
642
  const filePath = path.join(cwd, '.github', 'hooks', 'trackfw-attention.json')
643
+
644
+ const preToolUse = [{ type: 'command', bash: SIGNAL_CMD, cwd: '.', timeoutSec: 10 }]
645
+ const postToolUse = [{ type: 'command', bash: CLEANUP_CMD, cwd: '.', timeoutSec: 10 }]
646
+
647
+ // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A): skip project-scope
648
+ // credential-guard entries when the global one is already installed.
649
+ if (!globalCredentialGuardInstalledCopilot()) {
650
+ preToolUse.push({ type: 'command', matcher: 'bash', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
651
+ postToolUse.push({ type: 'command', matcher: 'bash', bash: GUARD_CMD, cwd: '.', timeoutSec: 10 })
652
+ }
653
+
216
654
  const data = {
217
- hooks: [
218
- {
219
- event: 'preToolUse',
220
- run: SIGNAL_CMD,
221
- },
222
- {
223
- event: 'postToolUse',
224
- run: CLEANUP_CMD,
225
- },
226
- ],
655
+ version: 1,
656
+ hooks: { preToolUse, postToolUse },
227
657
  }
228
658
  writeJSON(filePath, data)
229
659
  }
230
660
 
231
661
  // ---------------------------------------------------------------------------
232
662
  // Cursor — .cursor/hooks.json
663
+ //
664
+ // Two independent things are wired here, both nested under the real Cursor
665
+ // hook config `{"version": 1, "hooks": {"<eventName>": [...] }}`:
666
+ // - hooks.preToolUse + hooks.postToolUse (migrated by this ML) --
667
+ // attention-signal/cleanup. Prior to this ML these were written to
668
+ // top-level preToolUse/postToolUse arrays, which did not match any
669
+ // documented Cursor event (confirmed 2026-08-05, see docs/cli-parity.md
670
+ // "Cursor wiring (ML-2E)"). Re-fetching https://cursor.com/docs/hooks on
671
+ // 2026-08-06 (the /docs/agent/hooks URL now 308-redirects there) shows
672
+ // Cursor's docs were updated in the interim to add three new generic
673
+ // events: preToolUse/postToolUse/postToolUseFailure, "fires for all tool
674
+ // types (Shell, Read, Write, MCP, Task, etc.)". preToolUse's documented
675
+ // input is `{"tool_name","tool_input":{...},"tool_use_id","cwd",...}`
676
+ // and postToolUse's is the same shape plus `tool_output`/`duration` --
677
+ // structurally identical to Claude Code's PreToolUse/PostToolUse payload
678
+ // (`tool_name`/`tool_input`), which is exactly the shape
679
+ // scripts/trackfw-attention-signal.sh and trackfw-attention-cleanup.sh
680
+ // already parse (`.tool_name`, `.tool_input.question // .tool_input.command`).
681
+ // No script changes were needed. Per-hook `matcher` filters by tool type
682
+ // (e.g. "Shell|Read|Write") and is optional; intentionally omitted here,
683
+ // same reasoning as beforeShellExecution below -- the attention signal
684
+ // must fire for every tool use, not a filtered subset.
685
+ // - hooks.beforeShellExecution + hooks.afterShellExecution (ML-2E, prior
686
+ // cycle) -- credential-guard. beforeShellExecution is the real,
687
+ // Bash-specific, pre-execution event: input is
688
+ // `{"command","cwd","sandbox"}`, response (stdout JSON, only read on
689
+ // exit code 0) is `{"permission":"allow"|"deny"|"ask","user_message":"...",
690
+ // "agent_message":"..."}`. Per the documented "Exit code behavior": exit 0 uses the
691
+ // JSON output (or defaults to allow if stdout has none -- confirmed by the doc's own
692
+ // minimal example hook, which exits 0 with no stdout at all), exit 2 blocks the
693
+ // action ("equivalent to returning permission: \"deny\""), any other exit code
694
+ // fail-opens (hook failed, action proceeds). This is already exactly
695
+ // trackfw-credential-guard.sh's existing contract (block mode -> exit 2 + stderr, warn
696
+ // mode -> exit 0), so no script changes were needed to wire Cursor. afterShellExecution
697
+ // is a post-execution audit-only event (input adds "output"/"duration", no
698
+ // allow/deny/ask response defined) -- added in parallel for symmetry with the
699
+ // PostToolUse wiring already used for the other CLIs in this wave. Concurrency between
700
+ // hooks registered on the same event was not documented on the page retrieved for this
701
+ // investigation (unlike Codex, which explicitly documents concurrent execution); not
702
+ // assumed either way -- not a blocker here since this event array only ever contains
703
+ // the single credential-guard entry added by trackfw.
704
+ //
705
+ // Backward compatibility: a .cursor/hooks.json written by a pre-migration
706
+ // trackfw still has the legacy top-level preToolUse/postToolUse arrays. This
707
+ // function migrates known trackfw entries out of those top-level arrays into
708
+ // the nested hooks.preToolUse/hooks.postToolUse location, and drops the
709
+ // top-level key entirely once it is empty -- but never touches or deletes
710
+ // unrelated entries a user may have added there themselves (those keys are
711
+ // inert either way -- Cursor never read the top-level location -- so leaving
712
+ // them is harmless and avoids destroying unrelated user data on a guess).
233
713
  // ---------------------------------------------------------------------------
234
714
 
715
+ function removeKnownCommandFromLegacyTopLevelArray(data, key, command) {
716
+ if (!Array.isArray(data[key])) return
717
+ const kept = data[key].filter((item) => !(item && item.command === command))
718
+ if (kept.length === 0) {
719
+ delete data[key]
720
+ } else {
721
+ data[key] = kept
722
+ }
723
+ }
724
+
235
725
  function injectCursorHooks(cwd) {
236
726
  const filePath = path.join(cwd, '.cursor', 'hooks.json')
237
727
  const data = readJSON(filePath)
238
728
 
239
- if (!Array.isArray(data.preToolUse)) data.preToolUse = []
240
- if (!hasEntry(data.preToolUse, 'command', SIGNAL_CMD)) {
241
- data.preToolUse.push({ command: SIGNAL_CMD })
729
+ if (typeof data.version === 'undefined') data.version = 1
730
+ if (typeof data.hooks !== 'object' || data.hooks === null || Array.isArray(data.hooks)) {
731
+ data.hooks = {}
732
+ }
733
+
734
+ // Migrate any legacy top-level preToolUse/postToolUse trackfw entries
735
+ // (written by trackfw before this ML) into the nested, real hooks.
736
+ if (!Array.isArray(data.hooks.preToolUse)) data.hooks.preToolUse = []
737
+ if (!hasEntry(data.hooks.preToolUse, 'command', SIGNAL_CMD)) {
738
+ data.hooks.preToolUse.push({ command: SIGNAL_CMD })
242
739
  }
740
+ removeKnownCommandFromLegacyTopLevelArray(data, 'preToolUse', SIGNAL_CMD)
243
741
 
244
- if (!Array.isArray(data.postToolUse)) data.postToolUse = []
245
- if (!hasEntry(data.postToolUse, 'command', CLEANUP_CMD)) {
246
- data.postToolUse.push({ command: CLEANUP_CMD })
742
+ if (!Array.isArray(data.hooks.postToolUse)) data.hooks.postToolUse = []
743
+ if (!hasEntry(data.hooks.postToolUse, 'command', CLEANUP_CMD)) {
744
+ data.hooks.postToolUse.push({ command: CLEANUP_CMD })
745
+ }
746
+ removeKnownCommandFromLegacyTopLevelArray(data, 'postToolUse', CLEANUP_CMD)
747
+
748
+ // Dedup (ROADMAP-2026-08-06 Wave 3/ML-3A): skip project-scope
749
+ // credential-guard entries when the global one is already installed.
750
+ if (!globalCredentialGuardInstalledCursor()) {
751
+ if (!Array.isArray(data.hooks.beforeShellExecution)) data.hooks.beforeShellExecution = []
752
+ if (!hasEntry(data.hooks.beforeShellExecution, 'command', GUARD_CMD)) {
753
+ data.hooks.beforeShellExecution.push({ command: GUARD_CMD })
754
+ }
755
+
756
+ if (!Array.isArray(data.hooks.afterShellExecution)) data.hooks.afterShellExecution = []
757
+ if (!hasEntry(data.hooks.afterShellExecution, 'command', GUARD_CMD)) {
758
+ data.hooks.afterShellExecution.push({ command: GUARD_CMD })
759
+ }
247
760
  }
248
761
 
249
762
  writeJSON(filePath, data)
@@ -316,6 +829,8 @@ function injectHooksDetected(cwd) {
316
829
 
317
830
  module.exports = {
318
831
  generateAttentionScripts,
832
+ generateCredentialGuardScript,
833
+ generateGlobalCredentialGuardScript,
319
834
  injectClaudeHooks,
320
835
  injectCodexHooks,
321
836
  injectGeminiHooks,
@@ -324,4 +839,7 @@ module.exports = {
324
839
  injectCursorHooks,
325
840
  injectWindsurfHooks,
326
841
  injectHooksDetected,
842
+ mergeClaudeHookArray,
843
+ mergeSimpleCommandArray,
844
+ mergeCopilotHookArray,
327
845
  }
@@ -33,6 +33,7 @@ async function scaffold(cfg, cwd) {
33
33
  writeTrackfwConfig(cfg, root)
34
34
  generateValidateScript(cfg, root)
35
35
  generateAttentionScripts(cfg, root)
36
+ generateCredentialGuardScript(root)
36
37
  generateCIWorkflow(cfg, root)
37
38
  generateGitHooks(cfg, root)
38
39
  generateCommitMsgHook(cfg, root)
@@ -168,6 +169,11 @@ function generateAttentionScripts(cfg, cwd) {
168
169
  gen(cfg, cwd || process.cwd())
169
170
  }
170
171
 
172
+ function generateCredentialGuardScript(cwd) {
173
+ const { generateCredentialGuardScript: gen } = require('./hooks')
174
+ gen(cwd || process.cwd())
175
+ }
176
+
171
177
  function injectHooksDetected(cwd) {
172
178
  const { injectHooksDetected: inject } = require('./hooks')
173
179
  inject(cwd || process.cwd())
@@ -1322,6 +1328,7 @@ module.exports = {
1322
1328
  writeTrackfwConfig,
1323
1329
  generateValidateScript,
1324
1330
  generateAttentionScripts,
1331
+ generateCredentialGuardScript,
1325
1332
  generateCIWorkflow,
1326
1333
  generateGitHooks,
1327
1334
  generateCommitMsgHook,