trackfw 6.4.1 → 6.5.1
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/discover.js +6 -0
- package/src/commands/update-harness.js +422 -2
- package/src/commands/update.js +3 -1
- package/src/config/index.js +14 -0
- package/src/generators/codex.js +1 -25
- package/src/generators/hooks.js +547 -29
- package/src/generators/init.js +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.5.1",
|
|
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/discover.js
CHANGED
|
@@ -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, generateGlobalCredentialGuardScript } = 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
|
-
|
|
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 }))
|
|
@@ -185,6 +581,19 @@ function run(options) {
|
|
|
185
581
|
const dryRun = Boolean(options.dryRun)
|
|
186
582
|
const installMissing = Boolean(options.installMissing)
|
|
187
583
|
|
|
584
|
+
// The per-CLI *-credential-guard targets below only wire hook entries that
|
|
585
|
+
// point at ~/.trackfw/scripts/trackfw-credential-guard.sh — none of them
|
|
586
|
+
// write the script itself (ADR-2026-08-06, decision #2/#3). Without this
|
|
587
|
+
// call the wiring is installed but every hook invocation fails with
|
|
588
|
+
// "No such file or directory" because the script never exists.
|
|
589
|
+
if (!dryRun) {
|
|
590
|
+
if (options.json) {
|
|
591
|
+
silenceConsole(() => generateGlobalCredentialGuardScript(homeRoot))
|
|
592
|
+
} else {
|
|
593
|
+
generateGlobalCredentialGuardScript(homeRoot)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
188
597
|
// Identidade resolvida do disco antes de buildPlans — pular esta etapa
|
|
189
598
|
// reverteria silenciosamente os nomes customizados para os defaults
|
|
190
599
|
// neutros (mesma justificativa de npm/src/integrations/index.js:execute).
|
|
@@ -205,4 +614,15 @@ function run(options) {
|
|
|
205
614
|
if (doc.summary.failed > 0) process.exitCode = 1
|
|
206
615
|
}
|
|
207
616
|
|
|
208
|
-
module.exports = {
|
|
617
|
+
module.exports = {
|
|
618
|
+
run,
|
|
619
|
+
HARNESS_TARGET_IDS,
|
|
620
|
+
buildHarnessTargets,
|
|
621
|
+
claudeSkillContent,
|
|
622
|
+
credentialGuardTargetClaude,
|
|
623
|
+
credentialGuardTargetCodex,
|
|
624
|
+
credentialGuardTargetGemini,
|
|
625
|
+
credentialGuardTargetCursor,
|
|
626
|
+
credentialGuardTargetCopilot,
|
|
627
|
+
credentialGuardTargetKiro,
|
|
628
|
+
}
|
package/src/commands/update.js
CHANGED
|
@@ -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,
|
package/src/config/index.js
CHANGED
|
@@ -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
|
|
package/src/generators/codex.js
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const fs = require('fs')
|
|
4
|
-
const path = require('path')
|
|
5
|
-
const { injectCodexHooks } = require('./hooks')
|
|
6
|
-
const { injectRulesForTool } = require('./init')
|
|
7
|
-
|
|
8
3
|
const skills = {
|
|
9
4
|
'trackfw-governance': `---
|
|
10
5
|
name: trackfw-governance
|
|
@@ -81,23 +76,4 @@ Review the branch as an owner. Return findings first, ordered by severity, with
|
|
|
81
76
|
`,
|
|
82
77
|
}
|
|
83
78
|
|
|
84
|
-
|
|
85
|
-
const root = cwd || process.cwd()
|
|
86
|
-
const { execute } = require('../integrations')
|
|
87
|
-
execute('agents', 'install', { targets: ['codex'], scope: 'project' }, { projectRoot: root })
|
|
88
|
-
execute('skills', 'install', { targets: ['codex'], scope: 'project' }, { projectRoot: root })
|
|
89
|
-
injectRulesForTool('codex', root)
|
|
90
|
-
|
|
91
|
-
const configPath = path.join(root, '.codex', 'config.toml')
|
|
92
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true })
|
|
93
|
-
let config = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : ''
|
|
94
|
-
if (!/^\[agents\]\s*$/m.test(config)) {
|
|
95
|
-
config = config.replace(/\s*$/, '') + '\n\n[agents]\nmax_threads = 6\nmax_depth = 1\n'
|
|
96
|
-
fs.writeFileSync(configPath, config, 'utf8')
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
injectCodexHooks(root)
|
|
100
|
-
console.log(' ✓ Codex: AGENTS.md, skills, custom agents and hooks')
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
module.exports = { installCodex, legacyCodexFixtures: { skills, agents } }
|
|
79
|
+
module.exports = { legacyCodexFixtures: { skills, agents } }
|
package/src/generators/hooks.js
CHANGED
|
@@ -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
|
|
190
|
-
|
|
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-
|
|
193
|
-
description: '
|
|
194
|
-
|
|
195
|
-
matcher:
|
|
196
|
-
action: { type: 'command', command:
|
|
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-
|
|
200
|
-
description: '
|
|
201
|
-
|
|
202
|
-
matcher:
|
|
203
|
-
action: { type: 'command', command:
|
|
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
|
-
|
|
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 (
|
|
240
|
-
if (
|
|
241
|
-
data.
|
|
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
|
}
|
package/src/generators/init.js
CHANGED
|
@@ -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,
|