trackfw 7.5.0 → 7.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/barrier.js +104 -34
- package/src/commands/branch.js +4 -2
- package/src/commands/commit.js +4 -2
- package/src/commands/req.js +3 -2
- package/src/commands/roadmap.js +3 -2
- package/src/commands/serve.js +124 -17
- package/src/commands/update-harness.js +78 -16
- package/src/config/index.js +21 -0
- package/src/generators/hooks.js +1 -0
- package/src/generators/init.js +31 -6
- package/src/generators/req.js +11 -2
- package/src/generators/roadmap.js +79 -13
- package/src/integrations/index.js +22 -0
- package/src/integrations/register-agent.js +106 -0
- package/src/push/runner.js +5 -2
- package/src/serve/api_file.js +48 -11
- package/src/ship/runner.js +3 -2
- package/src/validator/index.js +52 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackfw",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.6.0",
|
|
4
4
|
"description": "Governance CLI for AI-native software delivery and AI coding agents. Enforces traceability: ADR → REQ → ROADMAP → kanban. Native support for Codex, Claude Code, Gemini CLI, Cursor, Copilot, Windsurf, Amazon Q, OpenCode, and Kiro.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
package/src/commands/barrier.js
CHANGED
|
@@ -471,73 +471,137 @@ function evalAcceptanceEvidence(mls) {
|
|
|
471
471
|
// roadmapTrustForGates determines whether the gates declared in a roadmap can
|
|
472
472
|
// be trusted for execution without --trust-local-gates.
|
|
473
473
|
//
|
|
474
|
-
//
|
|
475
|
-
//
|
|
474
|
+
// Posture (AC1): CLOSED by default. Gates execute only when the function can
|
|
475
|
+
// PROVE that the roadmap is present in refs/remotes/origin/main byte-for-byte.
|
|
476
|
+
// Absence of proof is absence of trust — every error path returns
|
|
477
|
+
// { trusted: false } with a named failureMsg (AC2). Exactly one
|
|
478
|
+
// { trusted: true } return, at the very end, after all proofs succeed.
|
|
476
479
|
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
480
|
+
// AC3: the discriminant never parses git stderr. Steps 4 and 5 use
|
|
481
|
+
// "git rev-parse --verify" and "git cat-file -e" whose exit codes answer
|
|
482
|
+
// the questions directly. The fully-qualified ref refs/remotes/origin/main
|
|
483
|
+
// is used throughout to prevent a local branch named "origin/main" from
|
|
484
|
+
// satisfying the trust anchor.
|
|
485
|
+
//
|
|
486
|
+
// Residual (declared in docs/cli-parity.md): Windows users with
|
|
487
|
+
// core.autocrlf=true may receive "content differs" for an otherwise-identical
|
|
488
|
+
// roadmap (LF vs CRLF). The check fails closed, so the residual is safe.
|
|
489
|
+
// roadmapTrustForGates determines whether the gates declared in a roadmap can
|
|
490
|
+
// be trusted for execution without --trust-local-gates.
|
|
491
|
+
//
|
|
492
|
+
// localContentBuf (Buffer) is the content already read by the caller — the same
|
|
493
|
+
// bytes used to parse gate commands. Comparing it here ensures the proof covers
|
|
494
|
+
// exactly what executes (F1 invariant: what is proved = what executes).
|
|
495
|
+
// The only remaining route from unverified bytes to sh -c is --trust-local-gates,
|
|
496
|
+
// which requires explicit operator consent.
|
|
497
|
+
function roadmapTrustForGates(roadmapPath, localContentBuf) {
|
|
481
498
|
const roadmapDir = path.dirname(roadmapPath)
|
|
482
499
|
|
|
483
500
|
// Step 1: check if we are inside a git repository.
|
|
501
|
+
// Two distinct failure reasons are separated here (F5):
|
|
502
|
+
// revParse.error → git binary not found in PATH (spawn failure)
|
|
503
|
+
// revParse.status → git ran but not inside a git repository
|
|
484
504
|
const revParse = spawnSync('git', ['rev-parse', '--git-dir'], {
|
|
485
505
|
cwd: roadmapDir,
|
|
486
506
|
encoding: 'utf8',
|
|
487
507
|
stdio: 'pipe',
|
|
488
508
|
})
|
|
509
|
+
if (revParse.error) {
|
|
510
|
+
// Spawn failure: git binary is not installed or not in PATH.
|
|
511
|
+
return {
|
|
512
|
+
trusted: false,
|
|
513
|
+
failureMsg: 'gates not evaluated: git not found in PATH — install git to evaluate local gates',
|
|
514
|
+
}
|
|
515
|
+
}
|
|
489
516
|
if (revParse.status !== 0) {
|
|
490
|
-
|
|
491
|
-
|
|
517
|
+
return {
|
|
518
|
+
trusted: false,
|
|
519
|
+
failureMsg: 'gates not evaluated: not a git repository — pass --trust-local-gates to evaluate local gates',
|
|
520
|
+
}
|
|
492
521
|
}
|
|
493
522
|
|
|
494
523
|
// Step 2: get the repository toplevel.
|
|
495
|
-
const
|
|
524
|
+
const topResult = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
496
525
|
cwd: roadmapDir,
|
|
497
526
|
encoding: 'utf8',
|
|
498
527
|
stdio: 'pipe',
|
|
499
528
|
})
|
|
500
|
-
if (
|
|
501
|
-
return {
|
|
529
|
+
if (topResult.status !== 0 || topResult.error) {
|
|
530
|
+
return {
|
|
531
|
+
trusted: false,
|
|
532
|
+
failureMsg: 'gates not evaluated: cannot resolve git repository root — pass --trust-local-gates to evaluate local gates',
|
|
533
|
+
}
|
|
502
534
|
}
|
|
503
|
-
const repoRoot =
|
|
535
|
+
const repoRoot = topResult.stdout.trim()
|
|
504
536
|
|
|
505
537
|
// Step 3: compute repo-relative path (always forward slashes for git).
|
|
506
538
|
const absRoadmap = path.resolve(roadmapPath)
|
|
507
539
|
const relPath = path.relative(repoRoot, absRoadmap).split(path.sep).join('/')
|
|
508
540
|
|
|
509
|
-
// Step 4:
|
|
510
|
-
|
|
541
|
+
// Step 4: verify that refs/remotes/origin/main resolves to a commit (AC3 —
|
|
542
|
+
// exit code only, no stderr parsing). Failure means: no remote named origin,
|
|
543
|
+
// origin/main never fetched, or wrong default branch name. All are untrusted.
|
|
544
|
+
const verify = spawnSync(
|
|
545
|
+
'git', ['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
|
|
546
|
+
{ cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' }
|
|
547
|
+
)
|
|
548
|
+
if (verify.status !== 0 || verify.error) {
|
|
549
|
+
return {
|
|
550
|
+
trusted: false,
|
|
551
|
+
failureMsg: 'gates not evaluated: origin/main ref not available — pass --trust-local-gates to evaluate local gates',
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Step 5: check whether the roadmap path exists in refs/remotes/origin/main
|
|
556
|
+
// (AC3 — "git cat-file -e" exits non-zero when the object does not exist).
|
|
557
|
+
const refPath = `refs/remotes/origin/main:${relPath}`
|
|
558
|
+
const catFile = spawnSync('git', ['cat-file', '-e', refPath], {
|
|
511
559
|
cwd: repoRoot,
|
|
512
560
|
encoding: 'utf8',
|
|
513
561
|
stdio: 'pipe',
|
|
514
562
|
})
|
|
515
|
-
if (
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
return {
|
|
520
|
-
trusted: false,
|
|
521
|
-
failureMsg: 'gates not evaluated: roadmap is not committed in origin/main — pass --trust-local-gates to evaluate local gates',
|
|
522
|
-
}
|
|
563
|
+
if (catFile.status !== 0 || catFile.error) {
|
|
564
|
+
return {
|
|
565
|
+
trusted: false,
|
|
566
|
+
failureMsg: 'gates not evaluated: roadmap is not committed in origin/main — pass --trust-local-gates to evaluate local gates',
|
|
523
567
|
}
|
|
524
|
-
// Other failures (no remote, not fetched) → fail-open.
|
|
525
|
-
return { trusted: true }
|
|
526
568
|
}
|
|
527
569
|
|
|
528
|
-
// Step
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
570
|
+
// Step 6: retrieve the file content from refs/remotes/origin/main.
|
|
571
|
+
// No encoding option → stdout is a Buffer for true byte-for-byte comparison (F4).
|
|
572
|
+
const show = spawnSync('git', ['show', refPath], {
|
|
573
|
+
cwd: repoRoot,
|
|
574
|
+
stdio: 'pipe',
|
|
575
|
+
})
|
|
576
|
+
if (show.status !== 0 || show.error) {
|
|
577
|
+
// The cat-file check already confirmed the object exists; this is an
|
|
578
|
+
// unexpected failure (e.g. transient I/O). Still fail closed.
|
|
579
|
+
return {
|
|
580
|
+
trusted: false,
|
|
581
|
+
failureMsg: 'gates not evaluated: cannot read roadmap from origin/main — pass --trust-local-gates to evaluate local gates',
|
|
582
|
+
}
|
|
534
583
|
}
|
|
535
|
-
|
|
584
|
+
|
|
585
|
+
// Step 7: compare content byte-for-byte.
|
|
586
|
+
// localContentBuf (the parameter) is the Buffer the caller already read — it
|
|
587
|
+
// is the same slice used to parse gate commands. No second read is performed
|
|
588
|
+
// here; the proof covers exactly what executes (F1 invariant).
|
|
589
|
+
// show.stdout is also a Buffer (no encoding option above) — true binary comparison (F4).
|
|
590
|
+
//
|
|
591
|
+
// Step 5 triple note (F5 — declared unseparable): git cat-file -e exits non-zero
|
|
592
|
+
// for (a) roadmap absent from origin/main, (b) relPath wrong (e.g. macOS symlink,
|
|
593
|
+
// mitigated by WORK_PHYS in check-barrier.sh), (c) unexpected I/O error. All are
|
|
594
|
+
// equally fail-closed; separating them without additional git invocations would
|
|
595
|
+
// expand the attack surface. Declared unseparable by design.
|
|
596
|
+
if (!localContentBuf.equals(show.stdout)) {
|
|
536
597
|
return {
|
|
537
598
|
trusted: false,
|
|
538
599
|
failureMsg: 'gates not evaluated: roadmap content differs from origin/main — pass --trust-local-gates to evaluate local gates',
|
|
539
600
|
}
|
|
540
601
|
}
|
|
602
|
+
|
|
603
|
+
// Proven: roadmap is present in refs/remotes/origin/main and byte-identical.
|
|
604
|
+
// The buffer compared is the same one parsed for gate commands.
|
|
541
605
|
return { trusted: true }
|
|
542
606
|
}
|
|
543
607
|
|
|
@@ -548,8 +612,10 @@ function roadmapTrustForGates(roadmapPath) {
|
|
|
548
612
|
const SH_MISSING_MSG =
|
|
549
613
|
'gates not evaluated: sh not found in PATH — install a POSIX shell (e.g. Git Bash, WSL) to evaluate gates'
|
|
550
614
|
|
|
551
|
-
function evalGates(commands, cwd, trustResult = { trusted:
|
|
615
|
+
function evalGates(commands, cwd, trustResult = { trusted: false }) {
|
|
552
616
|
// trustResult: { trusted: true } | { trusted: false, failureMsg: string }
|
|
617
|
+
// Default is { trusted: false } — fail-closed (F2). Every call site must pass
|
|
618
|
+
// an explicit trustResult; omitting it produces not_evaluated, not gate execution.
|
|
553
619
|
if (!trustResult.trusted) {
|
|
554
620
|
// Roadmap is not trusted: do not execute gates (AC3, AC14).
|
|
555
621
|
// Report as not_evaluated — distinct from passed and blocked (AC6).
|
|
@@ -669,7 +735,10 @@ async function runBarrier(roadmapArg, waveOption, jsonOutput, trustLocalGates) {
|
|
|
669
735
|
|
|
670
736
|
const cfg = config.load()
|
|
671
737
|
const resolved = resolveRoadmapFile(cfg, roadmapArg)
|
|
672
|
-
|
|
738
|
+
// Read as Buffer (no encoding) so the same bytes can be passed to roadmapTrustForGates
|
|
739
|
+
// for the byte-for-byte comparison (F1 invariant: what is proved = what executes).
|
|
740
|
+
const contentBuf = fs.readFileSync(resolved.path)
|
|
741
|
+
const content = contentBuf.toString('utf8')
|
|
673
742
|
const lines = splitRoadmapLines(content)
|
|
674
743
|
|
|
675
744
|
const startedAt = new Date()
|
|
@@ -678,9 +747,10 @@ async function runBarrier(roadmapArg, waveOption, jsonOutput, trustLocalGates) {
|
|
|
678
747
|
const gates = parseGates(lines, wave.startLine, wave.endLine)
|
|
679
748
|
|
|
680
749
|
// Determine trust for gate execution (AC11, AC12).
|
|
750
|
+
// Pass contentBuf so the trust check compares the same buffer parsed for gates.
|
|
681
751
|
const trustResult = trustLocalGates
|
|
682
752
|
? { trusted: true }
|
|
683
|
-
: roadmapTrustForGates(resolved.path)
|
|
753
|
+
: roadmapTrustForGates(resolved.path, contentBuf)
|
|
684
754
|
|
|
685
755
|
const checks = []
|
|
686
756
|
checks.push(evalMlsComplete(mls, waveLabel))
|
package/src/commands/branch.js
CHANGED
|
@@ -30,8 +30,10 @@ function createBranchCommand() {
|
|
|
30
30
|
" 4. With a match (or for chore/docs): runs 'git checkout -b <type>/<slug>', propagating\n" +
|
|
31
31
|
" Git's own output and exit status literally.\n\n" +
|
|
32
32
|
'Create the governance artifacts first if this blocks you:\n' +
|
|
33
|
-
' trackfw req new "title"\n' +
|
|
34
|
-
' trackfw
|
|
33
|
+
' trackfw req new "title" # flat project\n' +
|
|
34
|
+
' trackfw req new --agent <agent> "title" # by_agent project with 2+ agents\n' +
|
|
35
|
+
' trackfw roadmap new "title" # flat project\n' +
|
|
36
|
+
' trackfw roadmap new --agent <agent> "title" # by_agent project with 2+ agents\n' +
|
|
35
37
|
' trackfw roadmap move <name> wip'
|
|
36
38
|
)
|
|
37
39
|
.argument('<spec>', '<type>/<slug>, type in feat, fix, refactor, chore, docs')
|
package/src/commands/commit.js
CHANGED
|
@@ -34,8 +34,10 @@ function createCommitCommand() {
|
|
|
34
34
|
'ready-to-use message; review and edit before using it with -m. When \'--suggest\' is set,\n' +
|
|
35
35
|
"'-m' (if also passed) is ignored and no commit ever happens.\n\n" +
|
|
36
36
|
'Create the governance artifacts first if this blocks you:\n' +
|
|
37
|
-
' trackfw req new "title"\n' +
|
|
38
|
-
' trackfw
|
|
37
|
+
' trackfw req new "title" # flat project\n' +
|
|
38
|
+
' trackfw req new --agent <agent> "title" # by_agent project with 2+ agents\n' +
|
|
39
|
+
' trackfw roadmap new "title" # flat project\n' +
|
|
40
|
+
' trackfw roadmap new --agent <agent> "title" # by_agent project with 2+ agents\n' +
|
|
39
41
|
' trackfw roadmap move <name> wip'
|
|
40
42
|
)
|
|
41
43
|
.option('-m, --message <msg>', 'Commit message (required)')
|
package/src/commands/req.js
CHANGED
|
@@ -11,7 +11,8 @@ cmd.description(t('req.description'))
|
|
|
11
11
|
|
|
12
12
|
cmd.command('new <title>')
|
|
13
13
|
.description(t('req.new.description'))
|
|
14
|
-
.
|
|
14
|
+
.option('--agent <name>', 'Agent namespace to write the REQ into (required in by_agent with multiple namespaces)')
|
|
15
|
+
.action(async (title, opts) => {
|
|
15
16
|
const { input, select } = require('@inquirer/prompts')
|
|
16
17
|
const generators = require('../generators/req')
|
|
17
18
|
const adrGenerators = require('../generators/adr')
|
|
@@ -68,7 +69,7 @@ cmd.command('new <title>')
|
|
|
68
69
|
content.dependsOnADRs = [...new Set(generatedADRs)]
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
await generators.newREQ(content)
|
|
72
|
+
await generators.newREQ(content, opts.agent)
|
|
72
73
|
|
|
73
74
|
if (content.dependsOnADRs.length > 0) {
|
|
74
75
|
console.log(`\n${t('req.new.adrDraftsCreated')}`)
|
package/src/commands/roadmap.js
CHANGED
|
@@ -12,14 +12,15 @@ cmd.command('new')
|
|
|
12
12
|
.option('-t, --title <title>', 'Roadmap title (alias)')
|
|
13
13
|
.option('-r, --req <path>', 'Path to the linked REQ')
|
|
14
14
|
.option('--from-req <path>', 'Generate roadmap with ML stubs from REQ acceptance criteria')
|
|
15
|
+
.option('--agent <name>', 'Agent namespace to write the roadmap into (required in by_agent with multiple namespaces)')
|
|
15
16
|
.action(async (titleArg, opts) => {
|
|
16
17
|
if (opts.fromReq) {
|
|
17
|
-
newRoadmapFromReq(opts.fromReq)
|
|
18
|
+
newRoadmapFromReq(opts.fromReq, opts.agent)
|
|
18
19
|
return
|
|
19
20
|
}
|
|
20
21
|
const title = titleArg || opts.title || 'New Roadmap'
|
|
21
22
|
const reqPath = opts.req || ''
|
|
22
|
-
newRoadmap(title, reqPath)
|
|
23
|
+
newRoadmap(title, reqPath, opts.agent)
|
|
23
24
|
})
|
|
24
25
|
|
|
25
26
|
cmd.command('list')
|
package/src/commands/serve.js
CHANGED
|
@@ -13,6 +13,18 @@ const { handleAttention } = require('../serve/api_attention')
|
|
|
13
13
|
|
|
14
14
|
const STATIC_DIR = path.join(__dirname, '..', 'serve', 'static')
|
|
15
15
|
|
|
16
|
+
// REAL_STATIC_DIR é a versão canônica (symlinks resolvidos) de STATIC_DIR.
|
|
17
|
+
// Calculado uma vez no carregamento do módulo para canonicalizar ambos os lados
|
|
18
|
+
// na verificação de segurança do serveStatic (M-03: prevenção de escape via symlink).
|
|
19
|
+
// Se STATIC_DIR não existir no momento do carregamento, fica null e serveStatic
|
|
20
|
+
// retorna 404 para qualquer requisição de asset estático.
|
|
21
|
+
let REAL_STATIC_DIR
|
|
22
|
+
try {
|
|
23
|
+
REAL_STATIC_DIR = fs.realpathSync.native(STATIC_DIR)
|
|
24
|
+
} catch (_) {
|
|
25
|
+
REAL_STATIC_DIR = null
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
// Aviso pinado, byte-idêntico entre os 3 runtimes (Go, Node.js, Python) — ver
|
|
17
29
|
// docs/cli-parity.md "`trackfw serve` — endereço de escuta, `--host` e
|
|
18
30
|
// aviso de exposição". Emitido quando --host resolve para uma interface
|
|
@@ -55,6 +67,73 @@ function displayUrl(host, port) {
|
|
|
55
67
|
return `http://${host}:${port}`
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
// isValidHost reports whether host is acceptable as a --host argument.
|
|
71
|
+
// Accepts: 'localhost', valid IPv4, valid IPv6 literal (without zone ID),
|
|
72
|
+
// RFC-1123 hostname. Rejects anything else — in particular strings with shell
|
|
73
|
+
// metacharacters, or IPv6 scoped addresses (zone ID after '%').
|
|
74
|
+
//
|
|
75
|
+
// Zone IDs are rejected even when syntactically clean (e.g. 'fe80::1%eth0')
|
|
76
|
+
// because: (1) Go's net.ParseIP rejects them — parity requires all 3 CLIs to
|
|
77
|
+
// agree; (2) Python's ipaddress.ip_address() accepts any zone ID content,
|
|
78
|
+
// including cmd.exe metacharacters that list2cmdline does not quote; blocking
|
|
79
|
+
// '%' aligns both runtimes and closes the entire attack class rather than
|
|
80
|
+
// enumerating individual metacharacters.
|
|
81
|
+
//
|
|
82
|
+
// Espelha internal/serve/serve.go IsValidHost e _is_valid_host do Python.
|
|
83
|
+
function isValidHost(host) {
|
|
84
|
+
if (host === 'localhost') return true
|
|
85
|
+
// Reject IPv6 scoped addresses (zone ID): '%' in host means a zone
|
|
86
|
+
// identifier that Python's ipaddress accepts with any content (including
|
|
87
|
+
// cmd.exe metacharacters); Go rejects all scoped addresses; rejecting here
|
|
88
|
+
// makes all three runtimes agree on the same contract.
|
|
89
|
+
if (host.includes('%')) return false
|
|
90
|
+
const net = require('net')
|
|
91
|
+
if (net.isIPv4(host) || net.isIPv6(host)) return true
|
|
92
|
+
// RFC 1123 hostname: labels separated by dots, each [a-zA-Z0-9] or hyphens,
|
|
93
|
+
// starting and ending with alphanum. Max label length 63, total max 253.
|
|
94
|
+
if (host.length > 253) return false
|
|
95
|
+
return /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(host)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// browserArgv returns [cmd, args] for opening url in the default browser
|
|
99
|
+
// without shell interpolation — argv, never a shell string. This is the only
|
|
100
|
+
// safe form: exec(`open "${url}"`) interpolates url into a shell command and
|
|
101
|
+
// allows injection when url contains shell metacharacters.
|
|
102
|
+
//
|
|
103
|
+
// Darwin / Linux: 'open' / 'xdg-open' are invoked directly; no shell involved.
|
|
104
|
+
// Windows: spawn('cmd', ['/c', 'start', '', url]) passes url as a distinct
|
|
105
|
+
// argv element to CreateProcess — safer than exec(), but cmd.exe still
|
|
106
|
+
// re-parses its own metacharacters (& | ^ >) in the url portion. Defense for
|
|
107
|
+
// that residual is AC4 (isValidHost) rejecting metacharacter-bearing hosts
|
|
108
|
+
// before they reach this function.
|
|
109
|
+
//
|
|
110
|
+
// Exported for testing.
|
|
111
|
+
function browserArgv(platform, url) {
|
|
112
|
+
if (platform === 'darwin') return ['open', [url]]
|
|
113
|
+
if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]]
|
|
114
|
+
return ['xdg-open', [url]]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// openBrowser opens url in the default browser using spawn (argv, no shell).
|
|
118
|
+
// Exported for testing with a PATH shim.
|
|
119
|
+
//
|
|
120
|
+
// Preserves the original UX: warn when the opener exits non-zero (e.g. when
|
|
121
|
+
// xdg-open finds no handler) as well as when it cannot be launched (ENOENT).
|
|
122
|
+
// The old exec() warned via the callback err; spawn uses 'close' + 'error'.
|
|
123
|
+
function openBrowser(platform, url) {
|
|
124
|
+
const { spawn } = require('child_process')
|
|
125
|
+
const [cmd, args] = browserArgv(platform, url)
|
|
126
|
+
const proc = spawn(cmd, args)
|
|
127
|
+
proc.on('error', (err) => {
|
|
128
|
+
console.warn(`Não foi possível abrir o browser: ${err.message}`)
|
|
129
|
+
})
|
|
130
|
+
proc.on('close', (code) => {
|
|
131
|
+
if (code !== 0 && code !== null) {
|
|
132
|
+
console.warn(`Não foi possível abrir o browser: processo encerrou com código ${code}`)
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
58
137
|
// Mapa de extensão → Content-Type
|
|
59
138
|
const MIME = {
|
|
60
139
|
'.html': 'text/html; charset=utf-8',
|
|
@@ -67,7 +146,17 @@ const MIME = {
|
|
|
67
146
|
|
|
68
147
|
/**
|
|
69
148
|
* serveStatic serve arquivos do STATIC_DIR.
|
|
70
|
-
*
|
|
149
|
+
*
|
|
150
|
+
* Modelo de segurança — contenção em dois estágios (M-03):
|
|
151
|
+
*
|
|
152
|
+
* 1. Contenção léxica: path.resolve() normaliza ".." e o resultado deve começar
|
|
153
|
+
* com STATIC_DIR. Caminhos fora recebem 403 antes de tocar o disco.
|
|
154
|
+
*
|
|
155
|
+
* 2. Contenção física: fs.realpathSync.native() é chamado no arquivo pedido e
|
|
156
|
+
* comparado com REAL_STATIC_DIR (pré-computado no carregamento do módulo).
|
|
157
|
+
* Um symlink dentro de STATIC_DIR cujo destino físico aponta para fora → 403.
|
|
158
|
+
* Qualquer falha de canonicalização → 404.
|
|
159
|
+
*
|
|
71
160
|
* @param {string} urlPath - pathname da URL (ex: '/static/app.js')
|
|
72
161
|
* @param {http.ServerResponse} res
|
|
73
162
|
*/
|
|
@@ -76,25 +165,43 @@ function serveStatic(urlPath, res) {
|
|
|
76
165
|
const relative = urlPath.replace(/^\/static/, '') || '/index.html'
|
|
77
166
|
const resolved = path.resolve(path.join(STATIC_DIR, relative))
|
|
78
167
|
|
|
79
|
-
//
|
|
80
|
-
|
|
168
|
+
// ── Estágio 1: Contenção léxica ────────────────────────────────────────────
|
|
169
|
+
const lexicalStaticDir = path.resolve(STATIC_DIR)
|
|
170
|
+
if (!resolved.startsWith(lexicalStaticDir + path.sep) && resolved !== lexicalStaticDir) {
|
|
81
171
|
res.writeHead(403, { 'Content-Type': 'text/plain' })
|
|
82
172
|
res.end('Forbidden')
|
|
83
173
|
return
|
|
84
174
|
}
|
|
85
175
|
|
|
86
|
-
|
|
176
|
+
// ── Estágio 2: Contenção física ────────────────────────────────────────────
|
|
177
|
+
if (!REAL_STATIC_DIR) {
|
|
178
|
+
// STATIC_DIR não existe — nenhum arquivo pode ser servido.
|
|
179
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
180
|
+
res.end('Not Found')
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let realResolved
|
|
185
|
+
try {
|
|
186
|
+
realResolved = fs.realpathSync.native(resolved)
|
|
187
|
+
} catch (_) {
|
|
87
188
|
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
88
189
|
res.end('Not Found')
|
|
89
190
|
return
|
|
90
191
|
}
|
|
91
192
|
|
|
92
|
-
|
|
193
|
+
if (!realResolved.startsWith(REAL_STATIC_DIR + path.sep) && realResolved !== REAL_STATIC_DIR) {
|
|
194
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' })
|
|
195
|
+
res.end('Forbidden')
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const ext = path.extname(realResolved).toLowerCase()
|
|
93
200
|
const contentType = MIME[ext] || 'application/octet-stream'
|
|
94
201
|
|
|
95
202
|
let content
|
|
96
203
|
try {
|
|
97
|
-
content = fs.readFileSync(
|
|
204
|
+
content = fs.readFileSync(realResolved)
|
|
98
205
|
} catch (_) {
|
|
99
206
|
res.writeHead(500, { 'Content-Type': 'text/plain' })
|
|
100
207
|
res.end('Internal Server Error')
|
|
@@ -190,6 +297,14 @@ function createServeCommand() {
|
|
|
190
297
|
const port = parseInt(opts.port, 10) || 8080
|
|
191
298
|
const host = opts.host
|
|
192
299
|
|
|
300
|
+
// AC4 — validate --host before bind. Rejects metacharacter-bearing
|
|
301
|
+
// strings that would reach the browser-open path or the socket.
|
|
302
|
+
if (!isValidHost(host)) {
|
|
303
|
+
console.error(`trackfw serve: invalid --host value: ${host}`)
|
|
304
|
+
console.error('--host must be "localhost", a valid IPv4/IPv6 address, or an RFC-1123 hostname.')
|
|
305
|
+
process.exit(1)
|
|
306
|
+
}
|
|
307
|
+
|
|
193
308
|
const server = createServer(cfg, port)
|
|
194
309
|
|
|
195
310
|
if (!isLoopbackHost(host)) {
|
|
@@ -201,16 +316,8 @@ function createServeCommand() {
|
|
|
201
316
|
console.log(`trackfw serve: ${url}`)
|
|
202
317
|
|
|
203
318
|
if (opts.open !== false) {
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
const platform = process.platform
|
|
207
|
-
let openCmd
|
|
208
|
-
if (platform === 'darwin') openCmd = `open "${url}"`
|
|
209
|
-
else if (platform === 'win32') openCmd = `start "" "${url}"`
|
|
210
|
-
else openCmd = `xdg-open "${url}"`
|
|
211
|
-
exec(openCmd, (err) => {
|
|
212
|
-
if (err) console.warn(`Não foi possível abrir o browser: ${err.message}`)
|
|
213
|
-
})
|
|
319
|
+
// AC1 — use argv (spawn), never shell string interpolation (exec).
|
|
320
|
+
openBrowser(process.platform, url)
|
|
214
321
|
}
|
|
215
322
|
})
|
|
216
323
|
|
|
@@ -235,4 +342,4 @@ function createServeCommand() {
|
|
|
235
342
|
return cmd
|
|
236
343
|
}
|
|
237
344
|
|
|
238
|
-
module.exports = { createServeCommand, createServer, isLoopbackHost, displayUrl }
|
|
345
|
+
module.exports = { createServeCommand, createServer, isLoopbackHost, displayUrl, isValidHost, browserArgv, openBrowser, serveStatic }
|
|
@@ -7,7 +7,7 @@ const identityStore = require('../identity')
|
|
|
7
7
|
const projectConfig = require('../config')
|
|
8
8
|
const { catalog, buildPlans, IntegrationManager, globalGroupPath } = require('../integrations')
|
|
9
9
|
const { tildeify, validateTargets, buildDocument, humanReport, silenceConsole } = require('../lib/update-engine')
|
|
10
|
-
const { mergeClaudeHookArray, mergeSimpleCommandArray, mergeCopilotHookArray,
|
|
10
|
+
const { mergeClaudeHookArray, mergeSimpleCommandArray, mergeCopilotHookArray, GIT_BRANCH_GUARD_SCRIPT, GLOBAL_CREDENTIAL_GUARD_SCRIPT } = require('../generators/hooks')
|
|
11
11
|
const { homedir } = require('../homedir')
|
|
12
12
|
|
|
13
13
|
// `trackfw update harness` is the global counterpart to `trackfw update` —
|
|
@@ -73,6 +73,72 @@ Cadeia: **ADR → REQ → ROADMAP** · Estados: \`backlog / wip / blocked / done
|
|
|
73
73
|
`
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// gitBranchGuardScriptTarget — evaluates (and, unless --dry-run, applies) the
|
|
77
|
+
// global git-branch-guard shell script at
|
|
78
|
+
// ~/.trackfw/scripts/trackfw-git-branch-guard.sh.
|
|
79
|
+
//
|
|
80
|
+
// This target OWNS the script file. It compares content before writing so that
|
|
81
|
+
// an already up-to-date script reports "skipped" and its mtime is left unchanged
|
|
82
|
+
// (REQ-2026-09-09 AC3). Creation does not require --install-missing because
|
|
83
|
+
// ~/.trackfw/scripts/ is 100% trackfw-owned.
|
|
84
|
+
function gitBranchGuardScriptTarget(homeRoot, { dryRun }) {
|
|
85
|
+
const id = 'git-branch-guard-script'
|
|
86
|
+
const filePath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-git-branch-guard.sh')
|
|
87
|
+
const displayPath = tildeify(homeRoot, filePath)
|
|
88
|
+
const desired = GIT_BRANCH_GUARD_SCRIPT
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const exists = fs.existsSync(filePath)
|
|
92
|
+
const actual = exists ? fs.readFileSync(filePath, 'utf8') : null
|
|
93
|
+
|
|
94
|
+
if (exists && actual === desired) {
|
|
95
|
+
// Content identical — skip write, repair execute bit without touching mtime.
|
|
96
|
+
try { fs.chmodSync(filePath, 0o755) } catch (_) { /* best-effort */ }
|
|
97
|
+
return { id, state: 'skipped', path: displayPath }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (dryRun) return { id, state: 'updated', path: displayPath }
|
|
101
|
+
|
|
102
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
103
|
+
fs.writeFileSync(filePath, desired, { encoding: 'utf8', mode: 0o755 })
|
|
104
|
+
return { id, state: 'updated', path: displayPath }
|
|
105
|
+
} catch (e) {
|
|
106
|
+
return { id, state: 'failed', path: displayPath, message: e.message }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// credentialGuardScriptTarget — evaluates (and, unless --dry-run, applies) the
|
|
111
|
+
// global credential-guard shell script at
|
|
112
|
+
// ~/.trackfw/scripts/trackfw-credential-guard.sh.
|
|
113
|
+
//
|
|
114
|
+
// Mirrors gitBranchGuardScriptTarget — same ownership model, same idempotency
|
|
115
|
+
// contract, same absence of --install-missing gate.
|
|
116
|
+
function credentialGuardScriptTarget(homeRoot, { dryRun }) {
|
|
117
|
+
const id = 'credential-guard-script'
|
|
118
|
+
const filePath = path.join(homeRoot, '.trackfw', 'scripts', 'trackfw-credential-guard.sh')
|
|
119
|
+
const displayPath = tildeify(homeRoot, filePath)
|
|
120
|
+
const desired = GLOBAL_CREDENTIAL_GUARD_SCRIPT
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const exists = fs.existsSync(filePath)
|
|
124
|
+
const actual = exists ? fs.readFileSync(filePath, 'utf8') : null
|
|
125
|
+
|
|
126
|
+
if (exists && actual === desired) {
|
|
127
|
+
// Content identical — skip write, repair execute bit without touching mtime.
|
|
128
|
+
try { fs.chmodSync(filePath, 0o755) } catch (_) { /* best-effort */ }
|
|
129
|
+
return { id, state: 'skipped', path: displayPath }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (dryRun) return { id, state: 'updated', path: displayPath }
|
|
133
|
+
|
|
134
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
135
|
+
fs.writeFileSync(filePath, desired, { encoding: 'utf8', mode: 0o755 })
|
|
136
|
+
return { id, state: 'updated', path: displayPath }
|
|
137
|
+
} catch (e) {
|
|
138
|
+
return { id, state: 'failed', path: displayPath, message: e.message }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
76
142
|
function claudeSkillTarget(homeRoot, { dryRun, installMissing }) {
|
|
77
143
|
const id = 'claude-skill'
|
|
78
144
|
const filePath = path.join(homeRoot, '.claude', 'skills', 'trackfw', 'SKILL.md')
|
|
@@ -799,7 +865,10 @@ function catalogBundleTarget(toolId, kind, homeRoot, identityConfig, { dryRun, i
|
|
|
799
865
|
// which precede claude-agents/claude-skills), with credential-guard always
|
|
800
866
|
// preceding git-branch-guard within a tool — see buildHarnessTargetIDs's
|
|
801
867
|
// comment in update.go for the full rationale.
|
|
802
|
-
|
|
868
|
+
// Script targets precede every wiring target that references them so that even
|
|
869
|
+
// a --targets-filtered run that only requests a wiring target still finds the
|
|
870
|
+
// script already in place from a prior full run.
|
|
871
|
+
const HARNESS_TARGET_IDS = ['claude-skill', 'git-branch-guard-script', 'credential-guard-script', 'claude-credential-guard', 'claude-git-branch-guard']
|
|
803
872
|
for (const target of catalog.targets) {
|
|
804
873
|
if (target.id === 'codex') HARNESS_TARGET_IDS.push('codex-credential-guard', 'codex-git-branch-guard')
|
|
805
874
|
if (target.id === 'gemini') HARNESS_TARGET_IDS.push('gemini-credential-guard', 'gemini-git-branch-guard')
|
|
@@ -818,6 +887,8 @@ function buildHarnessTargets(homeRoot, identityConfig, { dryRun, installMissing
|
|
|
818
887
|
const include = (id) => !wanted || wanted.includes(id)
|
|
819
888
|
const targets = []
|
|
820
889
|
if (include('claude-skill')) targets.push(claudeSkillTarget(homeRoot, { dryRun, installMissing }))
|
|
890
|
+
if (include('git-branch-guard-script')) targets.push(gitBranchGuardScriptTarget(homeRoot, { dryRun }))
|
|
891
|
+
if (include('credential-guard-script')) targets.push(credentialGuardScriptTarget(homeRoot, { dryRun }))
|
|
821
892
|
if (include('claude-credential-guard')) targets.push(credentialGuardTargetClaude(homeRoot, { dryRun, installMissing }))
|
|
822
893
|
if (include('claude-git-branch-guard')) targets.push(gitBranchGuardTargetClaude(homeRoot, { dryRun, installMissing }))
|
|
823
894
|
for (const target of catalog.targets) {
|
|
@@ -885,20 +956,11 @@ function run(options) {
|
|
|
885
956
|
const dryRun = Boolean(options.dryRun)
|
|
886
957
|
const installMissing = Boolean(options.installMissing)
|
|
887
958
|
|
|
888
|
-
//
|
|
889
|
-
//
|
|
890
|
-
//
|
|
891
|
-
//
|
|
892
|
-
//
|
|
893
|
-
if (!dryRun) {
|
|
894
|
-
if (options.json) {
|
|
895
|
-
silenceConsole(() => generateGlobalCredentialGuardScript(homeRoot))
|
|
896
|
-
silenceConsole(() => generateGlobalGitBranchGuardScript(homeRoot))
|
|
897
|
-
} else {
|
|
898
|
-
generateGlobalCredentialGuardScript(homeRoot)
|
|
899
|
-
generateGlobalGitBranchGuardScript(homeRoot)
|
|
900
|
-
}
|
|
901
|
-
}
|
|
959
|
+
// NOTE: the script files are now first-class targets ("git-branch-guard-script",
|
|
960
|
+
// "credential-guard-script") positioned before the wiring targets in
|
|
961
|
+
// HARNESS_TARGET_IDS. The pre-loop unconditional writes that used to live here
|
|
962
|
+
// have been removed — the target functions handle creation and idempotency.
|
|
963
|
+
// (REQ-2026-09-09, ML-1A)
|
|
902
964
|
|
|
903
965
|
// Identidade resolvida do disco antes de buildPlans — pular esta etapa
|
|
904
966
|
// reverteria silenciosamente os nomes customizados para os defaults
|
package/src/config/index.js
CHANGED
|
@@ -356,6 +356,26 @@ function readAgentConventions(cwd) {
|
|
|
356
356
|
return cfg.update.agentConventions || '';
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
+
// readNamespacingConfig reads roadmap_namespacing and agents from <cwd>/trackfw.yaml, bypassing
|
|
360
|
+
// the load() singleton — mirrors Go's config.ReadNamespacingConfig. Never throws; returns
|
|
361
|
+
// defaults ('flat', []) on any failure.
|
|
362
|
+
function readNamespacingConfig(cwd) {
|
|
363
|
+
let content;
|
|
364
|
+
try {
|
|
365
|
+
content = fs.readFileSync(path.join(cwd || process.cwd(), 'trackfw.yaml'), 'utf8');
|
|
366
|
+
} catch (_) {
|
|
367
|
+
return { namespacing: 'flat', agents: [] };
|
|
368
|
+
}
|
|
369
|
+
const cfg = { rules: {}, credentialGuard: {}, update: {}, sync: {}, linkFields: {}, agentModels: {},
|
|
370
|
+
roadmapNamespacing: 'flat', agents: [] };
|
|
371
|
+
try {
|
|
372
|
+
parse(content, cfg);
|
|
373
|
+
} catch (_) {
|
|
374
|
+
return { namespacing: 'flat', agents: [] };
|
|
375
|
+
}
|
|
376
|
+
return { namespacing: cfg.roadmapNamespacing || 'flat', agents: cfg.agents || [] };
|
|
377
|
+
}
|
|
378
|
+
|
|
359
379
|
// _cwdAgentModelsSource returns 'project_only' if cwd's trackfw.yaml has agent_models configured,
|
|
360
380
|
// 'none' otherwise. Used for the AC14 diagnostic in loadGlobalAgentModels.
|
|
361
381
|
function _cwdAgentModelsSource(cwd) {
|
|
@@ -425,6 +445,7 @@ module.exports = {
|
|
|
425
445
|
expandPath,
|
|
426
446
|
parseRulesFromContent,
|
|
427
447
|
readAgentConventions,
|
|
448
|
+
readNamespacingConfig,
|
|
428
449
|
loadGlobalAgentModels,
|
|
429
450
|
resolveAgentModels,
|
|
430
451
|
NAMESPACING_FLAT,
|