@whitehatd/crag 0.2.2 → 0.2.4

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.
@@ -2,54 +2,87 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { EXIT_USER } = require('../cli-errors');
5
6
 
6
- function check() {
7
+ const CORE_CHECKS = [
8
+ ['.claude/skills/pre-start-context/SKILL.md', 'Pre-start skill (universal)'],
9
+ ['.claude/skills/post-start-validation/SKILL.md', 'Post-start skill (universal)'],
10
+ ['.claude/governance.md', 'Governance rules'],
11
+ ['.claude/hooks/drift-detector.sh', 'Drift detector hook'],
12
+ ['.claude/hooks/circuit-breaker.sh', 'Circuit breaker hook'],
13
+ ['.claude/agents/test-runner.md', 'Test runner agent'],
14
+ ['.claude/agents/security-reviewer.md', 'Security reviewer agent'],
15
+ ['.claude/ci-playbook.md', 'CI playbook'],
16
+ ['.claude/settings.local.json', 'Settings with hooks'],
17
+ ];
18
+
19
+ const OPTIONAL_CHECKS = [
20
+ ['.claude/.session-name', 'Session name (remote access)'],
21
+ ['.claude/hooks/pre-compact-snapshot.sh', 'Pre-compact hook (MemStack)'],
22
+ ['.claude/hooks/post-compact-recovery.sh', 'Post-compact hook (MemStack)'],
23
+ ['.claude/rules/knowledge.md', 'MemStack knowledge rule'],
24
+ ['.claude/rules/diary.md', 'MemStack diary rule'],
25
+ ['.claude/rules/echo.md', 'MemStack echo rule'],
26
+ ];
27
+
28
+ /**
29
+ * Probe the filesystem and return a structured report of crag infrastructure.
30
+ * Exported for testing — the CLI wraps this and prints.
31
+ */
32
+ function runChecks(cwd) {
33
+ const core = CORE_CHECKS.map(([file, name]) => ({
34
+ file,
35
+ name,
36
+ present: fs.existsSync(path.join(cwd, file)),
37
+ }));
38
+ const optional = OPTIONAL_CHECKS.map(([file, name]) => ({
39
+ file,
40
+ name,
41
+ present: fs.existsSync(path.join(cwd, file)),
42
+ }));
43
+ const missing = core.filter((c) => !c.present).length;
44
+ return {
45
+ cwd,
46
+ core,
47
+ optional,
48
+ missing,
49
+ total: core.length,
50
+ complete: missing === 0,
51
+ };
52
+ }
53
+
54
+ function check(args = []) {
7
55
  const cwd = process.cwd();
8
- const checks = [
9
- ['.claude/skills/pre-start-context/SKILL.md', 'Pre-start skill (universal)'],
10
- ['.claude/skills/post-start-validation/SKILL.md', 'Post-start skill (universal)'],
11
- ['.claude/governance.md', 'Governance rules'],
12
- ['.claude/hooks/drift-detector.sh', 'Drift detector hook'],
13
- ['.claude/hooks/circuit-breaker.sh', 'Circuit breaker hook'],
14
- ['.claude/agents/test-runner.md', 'Test runner agent'],
15
- ['.claude/agents/security-reviewer.md', 'Security reviewer agent'],
16
- ['.claude/ci-playbook.md', 'CI playbook'],
17
- ['.claude/settings.local.json', 'Settings with hooks'],
18
- ];
19
-
20
- const optional = [
21
- ['.claude/.session-name', 'Session name (remote access)'],
22
- ['.claude/hooks/pre-compact-snapshot.sh', 'Pre-compact hook (MemStack)'],
23
- ['.claude/hooks/post-compact-recovery.sh', 'Post-compact hook (MemStack)'],
24
- ['.claude/rules/knowledge.md', 'MemStack knowledge rule'],
25
- ['.claude/rules/diary.md', 'MemStack diary rule'],
26
- ['.claude/rules/echo.md', 'MemStack echo rule'],
27
- ];
56
+ const report = runChecks(cwd);
57
+
58
+ // --json: machine-readable output, no colors, no prose
59
+ if (args.includes('--json')) {
60
+ console.log(JSON.stringify(report, null, 2));
61
+ if (!report.complete) process.exit(EXIT_USER);
62
+ return;
63
+ }
28
64
 
29
65
  console.log(`\n Checking crag infrastructure in ${cwd}\n`);
30
66
 
31
67
  console.log(` Core:`);
32
- let missing = 0;
33
- for (const [file, name] of checks) {
34
- const exists = fs.existsSync(path.join(cwd, file));
35
- const icon = exists ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
36
- console.log(` ${icon} ${name}`);
37
- if (!exists) missing++;
68
+ for (const c of report.core) {
69
+ const icon = c.present ? '\x1b[32m✓\x1b[0m' : '\x1b[31m✗\x1b[0m';
70
+ console.log(` ${icon} ${c.name}`);
38
71
  }
39
72
 
40
73
  console.log(`\n Optional:`);
41
- for (const [file, name] of optional) {
42
- const exists = fs.existsSync(path.join(cwd, file));
43
- const icon = exists ? '\x1b[32m✓\x1b[0m' : '\x1b[90m○\x1b[0m';
44
- console.log(` ${icon} ${name}`);
74
+ for (const o of report.optional) {
75
+ const icon = o.present ? '\x1b[32m✓\x1b[0m' : '\x1b[90m○\x1b[0m';
76
+ console.log(` ${icon} ${o.name}`);
45
77
  }
46
78
 
47
- console.log(`\n ${checks.length - missing}/${checks.length} core files present.`);
48
- if (missing > 0) {
79
+ console.log(`\n ${report.total - report.missing}/${report.total} core files present.`);
80
+ if (report.missing > 0) {
49
81
  console.log(` Run 'crag init' to generate missing files.\n`);
82
+ process.exit(EXIT_USER);
50
83
  } else {
51
84
  console.log(` Infrastructure complete.\n`);
52
85
  }
53
86
  }
54
87
 
55
- module.exports = { check };
88
+ module.exports = { check, runChecks, CORE_CHECKS, OPTIONAL_CHECKS };
@@ -15,6 +15,7 @@ const { generateContinue } = require('../compile/continue');
15
15
  const { generateWindsurf } = require('../compile/windsurf');
16
16
  const { generateZed } = require('../compile/zed');
17
17
  const { generateCody } = require('../compile/cody');
18
+ const { cliError, readFileOrExit, EXIT_USER, EXIT_INTERNAL } = require('../cli-errors');
18
19
 
19
20
  // All supported compile targets in dispatch order.
20
21
  // Grouped: CI (3) + AI agent native (3) + AI agent extras (6)
@@ -36,16 +37,19 @@ const ALL_TARGETS = [
36
37
  function compile(args) {
37
38
  const targetIdx = args.indexOf('--target');
38
39
  const target = targetIdx !== -1 ? args[targetIdx + 1] : args[1];
40
+ const dryRun = args.includes('--dry-run');
39
41
  const cwd = process.cwd();
40
42
  const govPath = path.join(cwd, '.claude', 'governance.md');
41
43
 
42
44
  if (!fs.existsSync(govPath)) {
43
- console.error(' Error: No .claude/governance.md found. Run crag init first.');
44
- process.exit(1);
45
+ cliError('no .claude/governance.md found. Run crag init or crag analyze first.', EXIT_USER);
45
46
  }
46
47
 
47
- const content = fs.readFileSync(govPath, 'utf-8');
48
+ const content = readFileOrExit(fs, govPath, 'governance.md');
48
49
  const parsed = parseGovernance(content);
50
+ if (parsed.warnings && parsed.warnings.length > 0) {
51
+ for (const w of parsed.warnings) console.warn(` \x1b[33m!\x1b[0m ${w}`);
52
+ }
49
53
  const flat = flattenGates(parsed.gates);
50
54
  const gateCount = Object.values(flat).flat().length;
51
55
 
@@ -68,37 +72,80 @@ function compile(args) {
68
72
  console.log(' crag compile --target zed .zed/rules.md');
69
73
  console.log(' crag compile --target cody .sourcegraph/cody-instructions.md\n');
70
74
  console.log(' Combined:');
71
- console.log(' crag compile --target all All 12 targets at once\n');
75
+ console.log(' crag compile --target all All 12 targets at once');
76
+ console.log(' crag compile --target <t> --dry-run Preview paths without writing\n');
72
77
  return;
73
78
  }
74
79
 
75
80
  const targets = target === 'all' ? ALL_TARGETS : [target];
76
81
 
77
82
  console.log(`\n Compiling governance.md → ${targets.join(', ')}`);
78
- console.log(` ${gateCount} gates, ${parsed.runtimes.length} runtimes detected\n`);
83
+ console.log(` ${gateCount} gates, ${parsed.runtimes.length} runtimes detected${dryRun ? ' (dry-run)' : ''}\n`);
79
84
 
80
- for (const t of targets) {
81
- switch (t) {
82
- case 'github': generateGitHubActions(cwd, parsed); break;
83
- case 'husky': generateHusky(cwd, parsed); break;
84
- case 'pre-commit': generatePreCommitConfig(cwd, parsed); break;
85
- case 'agents-md': generateAgentsMd(cwd, parsed); break;
86
- case 'cursor': generateCursorRules(cwd, parsed); break;
87
- case 'gemini': generateGeminiMd(cwd, parsed); break;
88
- case 'copilot': generateCopilot(cwd, parsed); break;
89
- case 'cline': generateCline(cwd, parsed); break;
90
- case 'continue': generateContinue(cwd, parsed); break;
91
- case 'windsurf': generateWindsurf(cwd, parsed); break;
92
- case 'zed': generateZed(cwd, parsed); break;
93
- case 'cody': generateCody(cwd, parsed); break;
94
- default:
85
+ // --dry-run: print the planned output paths without writing files.
86
+ if (dryRun) {
87
+ for (const t of targets) {
88
+ const outPath = planOutputPath(cwd, t);
89
+ if (outPath) {
90
+ console.log(` \x1b[36mplan\x1b[0m ${path.relative(cwd, outPath)}`);
91
+ } else {
95
92
  console.error(` Unknown target: ${t}`);
96
93
  console.error(` Valid targets: ${ALL_TARGETS.join(', ')}, all, list`);
97
- process.exit(1);
94
+ process.exit(EXIT_USER);
95
+ }
98
96
  }
97
+ console.log('\n Dry-run complete — no files written.\n');
98
+ return;
99
+ }
100
+
101
+ try {
102
+ for (const t of targets) {
103
+ switch (t) {
104
+ case 'github': generateGitHubActions(cwd, parsed); break;
105
+ case 'husky': generateHusky(cwd, parsed); break;
106
+ case 'pre-commit': generatePreCommitConfig(cwd, parsed); break;
107
+ case 'agents-md': generateAgentsMd(cwd, parsed); break;
108
+ case 'cursor': generateCursorRules(cwd, parsed); break;
109
+ case 'gemini': generateGeminiMd(cwd, parsed); break;
110
+ case 'copilot': generateCopilot(cwd, parsed); break;
111
+ case 'cline': generateCline(cwd, parsed); break;
112
+ case 'continue': generateContinue(cwd, parsed); break;
113
+ case 'windsurf': generateWindsurf(cwd, parsed); break;
114
+ case 'zed': generateZed(cwd, parsed); break;
115
+ case 'cody': generateCody(cwd, parsed); break;
116
+ default:
117
+ console.error(` Unknown target: ${t}`);
118
+ console.error(` Valid targets: ${ALL_TARGETS.join(', ')}, all, list`);
119
+ process.exit(EXIT_USER);
120
+ }
121
+ }
122
+ } catch (err) {
123
+ cliError(`compile failed: ${err.message}`, EXIT_INTERNAL);
99
124
  }
100
125
 
101
126
  console.log('\n Done. Governance is now executable infrastructure.\n');
102
127
  }
103
128
 
104
- module.exports = { compile, ALL_TARGETS };
129
+ /**
130
+ * Map a target name to its destination path relative to cwd.
131
+ * Used by --dry-run to show what would be written.
132
+ */
133
+ function planOutputPath(cwd, target) {
134
+ const map = {
135
+ 'github': path.join(cwd, '.github', 'workflows', 'gates.yml'),
136
+ 'husky': path.join(cwd, '.husky', 'pre-commit'),
137
+ 'pre-commit': path.join(cwd, '.pre-commit-config.yaml'),
138
+ 'agents-md': path.join(cwd, 'AGENTS.md'),
139
+ 'cursor': path.join(cwd, '.cursor', 'rules', 'governance.mdc'),
140
+ 'gemini': path.join(cwd, 'GEMINI.md'),
141
+ 'copilot': path.join(cwd, '.github', 'copilot-instructions.md'),
142
+ 'cline': path.join(cwd, '.clinerules'),
143
+ 'continue': path.join(cwd, '.continuerules'),
144
+ 'windsurf': path.join(cwd, '.windsurfrules'),
145
+ 'zed': path.join(cwd, '.zed', 'rules.md'),
146
+ 'cody': path.join(cwd, '.sourcegraph', 'cody-instructions.md'),
147
+ };
148
+ return map[target] || null;
149
+ }
150
+
151
+ module.exports = { compile, ALL_TARGETS, planOutputPath };
@@ -4,6 +4,8 @@ const { execSync } = require('child_process');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { parseGovernance, flattenGates } = require('../governance/parse');
7
+ const { extractRunCommands, isGateCommand } = require('../governance/yaml-run');
8
+ const { cliError, EXIT_USER, EXIT_INTERNAL, readFileOrExit } = require('../cli-errors');
7
9
 
8
10
  /**
9
11
  * crag diff — compare governance.md against codebase reality.
@@ -13,12 +15,14 @@ function diff(args) {
13
15
  const govPath = path.join(cwd, '.claude', 'governance.md');
14
16
 
15
17
  if (!fs.existsSync(govPath)) {
16
- console.error(' Error: No .claude/governance.md found. Run crag init or crag analyze first.');
17
- process.exit(1);
18
+ cliError('no .claude/governance.md found. Run crag init or crag analyze first.', EXIT_USER);
18
19
  }
19
20
 
20
- const content = fs.readFileSync(govPath, 'utf-8');
21
+ const content = readFileOrExit(fs, govPath, 'governance.md');
21
22
  const parsed = parseGovernance(content);
23
+ if (parsed.warnings && parsed.warnings.length > 0) {
24
+ for (const w of parsed.warnings) console.warn(` \x1b[33m!\x1b[0m ${w}`);
25
+ }
22
26
  const flat = flattenGates(parsed.gates);
23
27
 
24
28
  console.log(`\n Governance vs Reality — ${parsed.name || 'project'}\n`);
@@ -169,46 +173,8 @@ function extractCIGateCommands(cwd) {
169
173
  return gates;
170
174
  }
171
175
 
172
- /**
173
- * Extract commands from YAML `run:` steps, handling block scalars.
174
- */
175
- function extractRunCommands(content) {
176
- const commands = [];
177
- const lines = content.split(/\r?\n/);
178
-
179
- for (let i = 0; i < lines.length; i++) {
180
- const line = lines[i];
181
- const m = line.match(/^(\s*)-?\s*run:\s*(.*)$/);
182
- if (!m) continue;
183
-
184
- const baseIndent = m[1].length;
185
- const rest = m[2].trim();
186
-
187
- if (/^[|>][+-]?\s*$/.test(rest)) {
188
- // Block scalar
189
- for (let j = i + 1; j < lines.length; j++) {
190
- const ln = lines[j];
191
- if (ln.trim() === '') continue;
192
- const indentMatch = ln.match(/^(\s*)/);
193
- if (indentMatch[1].length <= baseIndent) break;
194
- const trimmed = ln.trim();
195
- if (trimmed && !trimmed.startsWith('#')) commands.push(trimmed);
196
- }
197
- } else if (rest && !rest.startsWith('#')) {
198
- commands.push(rest.replace(/^["']|["']$/g, ''));
199
- }
200
- }
201
-
202
- return commands;
203
- }
204
-
205
- function isGateCommand(cmd) {
206
- const patterns = [
207
- /npm (run|test|ci)/, /npx /, /node /, /cargo /, /go (test|build|vet)/,
208
- /pytest/, /ruff/, /mypy/, /eslint/, /biome/, /prettier/, /tsc/, /gradle/,
209
- ];
210
- return patterns.some(p => p.test(cmd));
211
- }
176
+ // extractRunCommands and isGateCommand now live in src/governance/yaml-run.js
177
+ // and are re-exported below for backward compatibility with existing tests.
212
178
 
213
179
  /**
214
180
  * Normalize commands to a canonical form for equality comparison.
@@ -287,3 +253,4 @@ function hasNpmScript(cwd, script) {
287
253
  }
288
254
 
289
255
  module.exports = { diff, normalizeCmd, extractRunCommands, isGateCommand };
256
+
@@ -2,46 +2,66 @@
2
2
 
3
3
  const { execSync, spawn } = require('child_process');
4
4
  const fs = require('fs');
5
+ const os = require('os');
5
6
  const path = require('path');
7
+ const { cliError, EXIT_USER, EXIT_INTERNAL } = require('../cli-errors');
6
8
 
7
9
  const SRC = path.join(__dirname, '..');
8
10
  const AGENT_SRC = path.join(SRC, 'crag-agent.md');
9
11
  const PRE_START_SRC = path.join(SRC, 'skills', 'pre-start-context.md');
10
12
  const POST_START_SRC = path.join(SRC, 'skills', 'post-start-validation.md');
11
- const GLOBAL_AGENT_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.claude', 'agents');
13
+
14
+ // Use os.homedir() as the authoritative source — fall back only if it returns
15
+ // empty (extremely rare, e.g. stripped-down containers). Never pass undefined
16
+ // to path.join, which throws ERR_INVALID_ARG_TYPE.
17
+ function resolveHomeDir() {
18
+ const h = os.homedir();
19
+ if (h && h.length > 0) return h;
20
+ return process.env.HOME || process.env.USERPROFILE || os.tmpdir();
21
+ }
22
+
23
+ const GLOBAL_AGENT_DIR = path.join(resolveHomeDir(), '.claude', 'agents');
12
24
  const GLOBAL_AGENT_PATH = path.join(GLOBAL_AGENT_DIR, 'crag-project.md');
13
25
 
14
26
  function install() {
15
- if (!fs.existsSync(GLOBAL_AGENT_DIR)) {
16
- fs.mkdirSync(GLOBAL_AGENT_DIR, { recursive: true });
27
+ try {
28
+ if (!fs.existsSync(GLOBAL_AGENT_DIR)) {
29
+ fs.mkdirSync(GLOBAL_AGENT_DIR, { recursive: true });
30
+ }
31
+ fs.copyFileSync(AGENT_SRC, GLOBAL_AGENT_PATH);
32
+ console.log(` Installed crag-project agent to ${GLOBAL_AGENT_PATH}`);
33
+ console.log(` Run /crag-project from any Claude Code session.`);
34
+ } catch (err) {
35
+ cliError(`failed to install global agent: ${err.message}`, EXIT_INTERNAL);
17
36
  }
18
- fs.copyFileSync(AGENT_SRC, GLOBAL_AGENT_PATH);
19
- console.log(` Installed crag-project agent to ${GLOBAL_AGENT_PATH}`);
20
- console.log(` Run /crag-project from any Claude Code session.`);
21
37
  }
22
38
 
23
39
  function installSkills(targetDir) {
24
- const skillDirs = [
25
- path.join(targetDir, '.claude', 'skills', 'pre-start-context'),
26
- path.join(targetDir, '.claude', 'skills', 'post-start-validation'),
27
- path.join(targetDir, '.agents', 'workflows'),
28
- ];
29
-
30
- for (const dir of skillDirs) {
31
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
32
- }
40
+ try {
41
+ const skillDirs = [
42
+ path.join(targetDir, '.claude', 'skills', 'pre-start-context'),
43
+ path.join(targetDir, '.claude', 'skills', 'post-start-validation'),
44
+ path.join(targetDir, '.agents', 'workflows'),
45
+ ];
46
+
47
+ for (const dir of skillDirs) {
48
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
49
+ }
33
50
 
34
- // Copy skills
35
- fs.copyFileSync(PRE_START_SRC, path.join(targetDir, '.claude', 'skills', 'pre-start-context', 'SKILL.md'));
36
- fs.copyFileSync(POST_START_SRC, path.join(targetDir, '.claude', 'skills', 'post-start-validation', 'SKILL.md'));
51
+ // Copy skills
52
+ fs.copyFileSync(PRE_START_SRC, path.join(targetDir, '.claude', 'skills', 'pre-start-context', 'SKILL.md'));
53
+ fs.copyFileSync(POST_START_SRC, path.join(targetDir, '.claude', 'skills', 'post-start-validation', 'SKILL.md'));
37
54
 
38
- // Workflow copies (remove name: line)
39
- for (const [src, name] of [[PRE_START_SRC, 'pre-start-context.md'], [POST_START_SRC, 'post-start-validation.md']]) {
40
- const content = fs.readFileSync(src, 'utf-8').replace(/^name:.*\n/m, '');
41
- fs.writeFileSync(path.join(targetDir, '.agents', 'workflows', name), content);
42
- }
55
+ // Workflow copies (remove name: line)
56
+ for (const [src, name] of [[PRE_START_SRC, 'pre-start-context.md'], [POST_START_SRC, 'post-start-validation.md']]) {
57
+ const content = fs.readFileSync(src, 'utf-8').replace(/^name:.*\n/m, '');
58
+ fs.writeFileSync(path.join(targetDir, '.agents', 'workflows', name), content);
59
+ }
43
60
 
44
- console.log(` Installed universal skills to ${targetDir}`);
61
+ console.log(` Installed universal skills to ${targetDir}`);
62
+ } catch (err) {
63
+ cliError(`failed to install skills: ${err.message}`, EXIT_INTERNAL);
64
+ }
45
65
  }
46
66
 
47
67
  function init() {
@@ -53,7 +73,7 @@ function init() {
53
73
  } catch {
54
74
  console.error(' Error: Claude Code CLI not found (or did not respond in 5s).');
55
75
  console.error(' Install: https://claude.com/claude-code');
56
- process.exit(1);
76
+ process.exit(EXIT_USER);
57
77
  }
58
78
 
59
79
  // Pre-flight: warn if not a git repo (non-blocking, just informative)
@@ -86,14 +106,18 @@ function init() {
86
106
  console.log(` The universal skills are already installed.\n`);
87
107
  console.log(` >>> Type "go" and press Enter to start the interview <<<\n`);
88
108
 
89
- const claude = spawn('claude', ['--agent', 'crag-project'], {
109
+ // On Windows, `shell: true` defaults to cmd.exe which can't always resolve
110
+ // the `claude` binary the way PowerShell / Git Bash can. Use an explicit
111
+ // shell path on Windows (bash from Git for Windows is the common install).
112
+ const spawnOpts = {
90
113
  stdio: 'inherit',
91
- shell: true,
92
- });
114
+ shell: process.platform === 'win32' ? (process.env.SHELL || 'bash') : true,
115
+ };
116
+ const claude = spawn('claude', ['--agent', 'crag-project'], spawnOpts);
93
117
 
94
118
  claude.on('error', (err) => {
95
119
  console.error(`\n Error launching claude: ${err.message}`);
96
- process.exit(1);
120
+ process.exit(EXIT_USER);
97
121
  });
98
122
 
99
123
  claude.on('exit', (code, signal) => {
@@ -101,7 +125,7 @@ function init() {
101
125
  console.log(`\n crag setup complete. Run 'crag check' to verify.`);
102
126
  } else if (signal) {
103
127
  console.error(`\n Interview terminated by signal: ${signal}`);
104
- process.exit(1);
128
+ process.exit(EXIT_USER);
105
129
  } else if (code !== null) {
106
130
  console.error(`\n Interview exited with code ${code}`);
107
131
  process.exit(code);
@@ -109,4 +133,4 @@ function init() {
109
133
  });
110
134
  }
111
135
 
112
- module.exports = { init, install, installSkills };
136
+ module.exports = { init, install, installSkills, resolveHomeDir };
@@ -1,31 +1,39 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
3
4
  const fs = require('fs');
4
5
  const path = require('path');
5
6
 
6
7
  /**
7
8
  * Write `content` to `filePath` atomically:
8
9
  * 1. Ensure parent directory exists
9
- * 2. Write to a sibling tempfile
10
+ * 2. Write to a sibling tempfile (unpredictable suffix — crypto-random)
10
11
  * 3. Rename tempfile over destination
11
12
  *
12
13
  * If any step fails, the tempfile is cleaned up and the original destination
13
14
  * remains untouched. Prevents partial-write state if the process is killed
14
15
  * mid-write or the filesystem runs out of space.
16
+ *
17
+ * The random suffix makes the temp filename unpredictable, blocking
18
+ * race-condition attacks on shared filesystems where an attacker could
19
+ * pre-create a symlink at the expected temp path.
15
20
  */
16
21
  function atomicWrite(filePath, content) {
17
22
  const dir = path.dirname(filePath);
18
23
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
19
24
 
20
- const tmp = filePath + '.tmp.' + process.pid + '.' + Date.now();
25
+ const suffix = crypto.randomBytes(8).toString('hex');
26
+ const tmp = `${filePath}.tmp.${suffix}`;
21
27
 
22
28
  try {
23
- fs.writeFileSync(tmp, content);
29
+ // wx flag: fail if the temp path somehow already exists (defense-in-depth
30
+ // on top of the unpredictable suffix).
31
+ fs.writeFileSync(tmp, content, { flag: 'wx' });
24
32
  fs.renameSync(tmp, filePath);
25
33
  } catch (err) {
26
34
  // Best-effort cleanup
27
35
  try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch {}
28
- throw err;
36
+ throw new Error(`atomicWrite failed for ${filePath}: ${err.message}`);
29
37
  }
30
38
  }
31
39
 
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const { gateToShell } = require('../governance/gate-to-shell');
6
6
  const { flattenGatesRich } = require('../governance/parse');
7
7
  const { atomicWrite } = require('./atomic-write');
8
+ const { yamlScalar } = require('../update/integrity');
8
9
 
9
10
  /**
10
11
  * Extract the major Node version from package.json engines.node field.
@@ -95,7 +96,10 @@ function generateGitHubActions(cwd, parsed) {
95
96
  setupSteps += ' - name: Setup Python\n';
96
97
  setupSteps += ' uses: actions/setup-python@v5\n';
97
98
  setupSteps += ` with:\n python-version: '${pythonVersion}'\n`;
98
- setupSteps += ' - run: pip install -r requirements.txt 2>/dev/null || true\n';
99
+ // Explicit `shell: bash` so redirects work on Windows runners (which default to cmd.exe).
100
+ setupSteps += ' - name: Install Python deps (best-effort)\n';
101
+ setupSteps += ' shell: bash\n';
102
+ setupSteps += ' run: pip install -r requirements.txt 2>/dev/null || true\n';
99
103
  }
100
104
  if (parsed.runtimes.includes('java')) {
101
105
  setupSteps += ' - name: Setup Java\n';
@@ -108,13 +112,10 @@ function generateGitHubActions(cwd, parsed) {
108
112
  setupSteps += ` with:\n go-version: '${goVersion}'\n`;
109
113
  }
110
114
 
111
- // Escape for YAML double-quoted scalar: \, ", and control chars.
112
- const yamlDqEscape = (s) => String(s)
113
- .replace(/\\/g, '\\\\')
114
- .replace(/"/g, '\\"')
115
- .replace(/\r/g, '\\r')
116
- .replace(/\n/g, '\\n')
117
- .replace(/\t/g, '\\t');
115
+ // GHA expression escape for strings inside hashFiles('...'):
116
+ // single quotes are doubled. The value is already validated to be a
117
+ // relative in-repo path by the parser, but we escape defensively.
118
+ const ghaExprEscape = (s) => String(s).replace(/'/g, "''");
118
119
 
119
120
  let gateSteps = '';
120
121
  for (const gate of flattenGatesRich(parsed.gates)) {
@@ -122,12 +123,17 @@ function generateGitHubActions(cwd, parsed) {
122
123
  const label = gate.cmd.length > 60 ? gate.cmd.substring(0, 57) + '...' : gate.cmd;
123
124
  const prefix = gate.classification !== 'MANDATORY' ? `[${gate.classification}] ` : '';
124
125
  const condExpr = gate.condition ? ` (if: ${gate.condition})` : '';
125
- const workDir = gate.path ? `\n working-directory: ${gate.path}` : '';
126
+ // Route gate.path through yamlScalar — it will quote if the path contains
127
+ // YAML-sensitive characters (colon, #, quotes, etc.).
128
+ const workDir = gate.path ? `\n working-directory: ${yamlScalar(gate.path)}` : '';
126
129
  const contOnErr = (gate.classification === 'OPTIONAL' || gate.classification === 'ADVISORY')
127
130
  ? '\n continue-on-error: true' : '';
128
131
  const ifGuard = gate.condition
129
- ? `\n if: hashFiles('${gate.condition}') != ''` : '';
130
- gateSteps += ` - name: "${prefix}${yamlDqEscape(gate.section)}: ${yamlDqEscape(label)}${yamlDqEscape(condExpr)}"${ifGuard}${workDir}${contOnErr}\n`;
132
+ ? `\n if: hashFiles('${ghaExprEscape(gate.condition)}') != ''` : '';
133
+ // Use yamlScalar for the name field so user input can never break the YAML
134
+ // structure even if it contains quotes, newlines, colons, or control chars.
135
+ const stepName = `${prefix}${gate.section}: ${label}${condExpr}`;
136
+ gateSteps += ` - name: ${yamlScalar(stepName)}${ifGuard}${workDir}${contOnErr}\n`;
131
137
  gateSteps += ` run: |\n ${shell.replace(/\n/g, '\n ')}\n`;
132
138
  }
133
139
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { gateToShell } = require('../governance/gate-to-shell');
5
+ const { gateToShell, shellEscapeDoubleQuoted } = require('../governance/gate-to-shell');
6
6
  const { flattenGatesRich } = require('../governance/parse');
7
7
  const { atomicWrite } = require('./atomic-write');
8
8
 
@@ -22,9 +22,10 @@ function generateHusky(cwd, parsed) {
22
22
  body += `# ${section}\n`;
23
23
  for (const gate of gates) {
24
24
  const shell = gateToShell(gate.cmd);
25
- // Quote path/condition for shell safety
26
- const quotedPath = gate.path ? gate.path.replace(/"/g, '\\"') : null;
27
- const quotedCond = gate.condition ? gate.condition.replace(/"/g, '\\"') : null;
25
+ // Escape path/condition for the double-quoted shell context.
26
+ // The escaper handles \ before " so the replacements don't overlap.
27
+ const quotedPath = gate.path ? shellEscapeDoubleQuoted(gate.path) : null;
28
+ const quotedCond = gate.condition ? shellEscapeDoubleQuoted(gate.condition) : null;
28
29
 
29
30
  // Build the core command (with cd if path-scoped)
30
31
  const coreCmd = quotedPath ? `(cd "${quotedPath}" && ${shell})` : shell;
@@ -32,7 +33,7 @@ function generateHusky(cwd, parsed) {
32
33
  // Build failure handler based on classification
33
34
  let onFail;
34
35
  if (gate.classification === 'OPTIONAL' || gate.classification === 'ADVISORY') {
35
- const escLabel = shell.replace(/"/g, '\\"');
36
+ const escLabel = shellEscapeDoubleQuoted(shell);
36
37
  onFail = `echo " [${gate.classification}] Gate failed: ${escLabel}"`;
37
38
  } else {
38
39
  onFail = 'exit 1';
@@ -2,9 +2,10 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
- const { gateToShell } = require('../governance/gate-to-shell');
5
+ const { gateToShell, shellEscapeDoubleQuoted, shellEscapeSingleQuoted } = require('../governance/gate-to-shell');
6
6
  const { flattenGatesRich } = require('../governance/parse');
7
7
  const { atomicWrite } = require('./atomic-write');
8
+ const { yamlScalar } = require('../update/integrity');
8
9
 
9
10
  function generatePreCommitConfig(cwd, parsed) {
10
11
  const gates = flattenGatesRich(parsed.gates);
@@ -17,16 +18,23 @@ function generatePreCommitConfig(cwd, parsed) {
17
18
  const truncated = name.length > 60 ? name.substring(0, 57) + '...' : name;
18
19
 
19
20
  let shell = gateToShell(gate.cmd);
20
- if (gate.path) shell = `cd "${gate.path}" && ${shell}`;
21
- if (gate.condition) shell = `[ -e "${gate.condition}" ] && (${shell}) || true`;
21
+ // gate.path and gate.condition come from user-authored governance.md.
22
+ // Parser rejects absolute paths and `..`, but contents still need to be
23
+ // escaped for the surrounding double-quoted shell context.
24
+ if (gate.path) {
25
+ shell = `cd "${shellEscapeDoubleQuoted(gate.path)}" && ${shell}`;
26
+ }
27
+ if (gate.condition) {
28
+ shell = `[ -e "${shellEscapeDoubleQuoted(gate.condition)}" ] && (${shell}) || true`;
29
+ }
22
30
  // For OPTIONAL/ADVISORY: never fail the hook
23
31
  if (gate.classification === 'OPTIONAL' || gate.classification === 'ADVISORY') {
24
32
  shell = `(${shell}) || echo "[${gate.classification}] failed — continuing"`;
25
33
  }
26
34
 
27
35
  hooks += ` - id: ${id}\n`;
28
- hooks += ` name: "${truncated.replace(/"/g, '\\"')}"\n`;
29
- hooks += ` entry: bash -c '${shell.replace(/'/g, "'\\''")}'\n`;
36
+ hooks += ` name: ${yamlScalar(truncated)}\n`;
37
+ hooks += ` entry: bash -c '${shellEscapeSingleQuoted(shell)}'\n`;
30
38
  hooks += ' language: system\n';
31
39
  hooks += ' pass_filenames: false\n';
32
40
  hooks += ' always_run: true\n';