@haposoft/cafekit 0.15.1 → 0.16.0-rc.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/README.md +6 -2
- package/bin/__tests__/codex-hooks.test.js +140 -0
- package/bin/__tests__/codex-native.test.js +230 -2
- package/bin/__tests__/develop-contract.test.js +1120 -0
- package/bin/__tests__/installer-safety.test.js +553 -2
- package/bin/__tests__/package-inventory.test.js +258 -0
- package/bin/__tests__/secret-scanner.test.js +157 -0
- package/bin/__tests__/validator-grounding.test.js +713 -0
- package/bin/install.js +5 -1
- package/bin/lib/codex-install.js +21 -6
- package/bin/lib/context.js +17 -6
- package/bin/lib/copy-utils.js +31 -0
- package/bin/lib/i18n.js +12 -3
- package/bin/lib/instruction-blocks.js +85 -0
- package/bin/lib/managed-writer.js +4 -1
- package/bin/lib/opencode-install.js +75 -30
- package/bin/lib/skill-deps.js +45 -7
- package/bin/phases/claude-runtime.js +103 -4
- package/bin/phases/codex-runtime.js +41 -7
- package/bin/phases/post-install.js +29 -22
- package/bin/phases/root-config.js +89 -10
- package/bin/phases/select-platform.js +23 -0
- package/bin/phases/skills-setup.js +7 -3
- package/package.json +15 -5
- package/src/claude/CLAUDE.md +6 -122
- package/src/claude/agents/code-auditor.md +21 -22
- package/src/claude/agents/debugger.md +3 -3
- package/src/claude/agents/deployer.md +1 -1
- package/src/claude/agents/{god-developer.md → implementer.md} +2 -2
- package/src/claude/agents/project-manager.md +1 -1
- package/src/claude/agents/spec-maker.md +2 -2
- package/src/claude/hooks/__tests__/spec-gate.test.js +356 -5
- package/src/claude/hooks/agent.cjs +7 -14
- package/src/claude/hooks/rules.cjs +33 -53
- package/src/claude/hooks/spec-gate.cjs +66 -21
- package/src/claude/hooks/spec-state.cjs +5 -0
- package/src/claude/migration-manifest.json +9 -2
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +1 -1
- package/src/claude/scripts/scan-staged-secrets.cjs +170 -0
- package/src/claude/scripts/spec-ground.cjs +189 -83
- package/src/claude/scripts/validate-spec-output.cjs +294 -30
- package/src/claude/scripts/workflow-policy.cjs +609 -0
- package/src/claude/skills/ai-multimodal/SKILL.md +2 -2
- package/src/claude/skills/ai-multimodal/references/audio-processing.md +6 -6
- package/src/claude/skills/ai-multimodal/references/video-analysis.md +1 -1
- package/src/claude/skills/backend-development/SKILL.md +4 -4
- package/src/claude/skills/backend-development/references/backend-architecture.md +1 -1
- package/src/claude/skills/backend-development/references/backend-devops.md +2 -2
- package/src/claude/skills/backend-development/references/backend-mindset.md +3 -3
- package/src/claude/skills/backend-development/references/backend-performance.md +5 -5
- package/src/claude/skills/backend-development/references/backend-security.md +3 -3
- package/src/claude/skills/backend-development/references/backend-technologies.md +5 -5
- package/src/claude/skills/backend-development/references/backend-testing.md +2 -2
- package/src/claude/skills/brainstorm/SKILL.md +2 -3
- package/src/claude/skills/chrome-devtools/scripts/package-lock.json +261 -150
- package/src/claude/skills/chrome-devtools/scripts/package.json +1 -1
- package/src/claude/skills/code-review/SKILL.md +12 -8
- package/src/claude/skills/code-review/references/verification-gate.md +4 -4
- package/src/claude/skills/debug/SKILL.md +2 -0
- package/src/claude/skills/develop/SKILL.md +100 -52
- package/src/claude/skills/develop/references/parallel-waves.md +105 -26
- package/src/claude/skills/develop/references/quality-gate.md +58 -38
- package/src/claude/skills/develop/references/subagent-patterns.md +2 -2
- package/src/claude/skills/git/SKILL.md +44 -50
- package/src/claude/skills/hotfix/SKILL.md +10 -8
- package/src/claude/skills/hotfix/references/parallel-patterns.md +2 -2
- package/src/claude/skills/hotfix/references/review-cycle.md +26 -21
- package/src/claude/skills/inspect/SKILL.md +12 -30
- package/src/claude/skills/mobile-development/SKILL.md +12 -12
- package/src/claude/skills/mobile-development/references/mobile-android.md +7 -7
- package/src/claude/skills/mobile-development/references/mobile-best-practices.md +22 -23
- package/src/claude/skills/mobile-development/references/mobile-frameworks.md +13 -13
- package/src/claude/skills/mobile-development/references/mobile-ios.md +1 -1
- package/src/claude/skills/mobile-development/references/mobile-mindset.md +17 -19
- package/src/claude/skills/pptx/scripts/package-lock.json +868 -0
- package/src/claude/skills/pptx/scripts/package.json +1 -1
- package/src/claude/skills/question/SKILL.md +13 -13
- package/src/claude/skills/research/SKILL.md +2 -2
- package/src/claude/skills/specs/SKILL.md +32 -15
- package/src/claude/skills/specs/references/review.md +3 -3
- package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
- package/src/claude/skills/specs/templates/spec-state.json +7 -3
- package/src/claude/skills/sync/SKILL.md +26 -1
- package/src/claude/skills/sync/references/sync-protocols.md +3 -3
- package/src/claude/skills/test/SKILL.md +12 -4
- package/src/claude/skills/test/references/failure-triage.md +17 -17
- package/src/claude/skills/test/references/test-memory.md +2 -2
- package/src/codex/AGENTS.md +16 -24
- package/src/codex/hooks/lib/spec-receipt.cjs +57 -4
- package/src/codex/hooks/rules.cjs +27 -26
- package/src/codex/hooks/spec-gate.cjs +19 -8
- package/src/codex/hooks/spec-state.cjs +10 -1
- package/src/codex/hooks.json +14 -14
- package/src/common/AGENTS.md +43 -0
- package/src/opencode/AGENTS.md +21 -121
- package/src/opencode/plugins/rules.ts +26 -34
- package/src/opencode/plugins/spec-gate.ts +283 -0
- package/src/claude/skills/inspect/references/external-gemini-inspection.md +0 -169
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const { test } = require('node:test');
|
|
9
|
+
|
|
10
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '../..');
|
|
11
|
+
const REQUIRED_PAYLOAD = [
|
|
12
|
+
'bin/install.js',
|
|
13
|
+
'src/claude/migration-manifest.json',
|
|
14
|
+
'src/claude/scripts/scan-staged-secrets.cjs',
|
|
15
|
+
'src/claude/scripts/workflow-policy.cjs',
|
|
16
|
+
];
|
|
17
|
+
const FORBIDDEN_PAYLOAD = [
|
|
18
|
+
/(^|\/)\.logs(\/|$)/,
|
|
19
|
+
/\.log$/,
|
|
20
|
+
/\.coverage(?:\/|$)/,
|
|
21
|
+
/__pycache__(?:\/|$)/,
|
|
22
|
+
/\.pyc$/,
|
|
23
|
+
/(^|\/)\.(?:cache|state|tmp)(\/|$)/,
|
|
24
|
+
/^src\/\.codex(?:\/|$)/,
|
|
25
|
+
];
|
|
26
|
+
const RUNTIMES = {
|
|
27
|
+
claude: { root: '.claude', rules: '.claude/hooks/rules.cjs' },
|
|
28
|
+
codex: { root: '.codex', rules: '.codex/hooks/rules.cjs' },
|
|
29
|
+
opencode: { root: '.opencode', rules: '.opencode/plugins/rules.ts' },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function npmPack(args, cwd) {
|
|
33
|
+
const result = spawnSync('npm', ['pack', '--ignore-scripts', ...args], {
|
|
34
|
+
cwd,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
});
|
|
37
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
38
|
+
return JSON.parse(result.stdout)[0];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function packedInventory(tarball) {
|
|
42
|
+
const result = spawnSync('tar', ['-tzf', tarball], { encoding: 'utf8' });
|
|
43
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
44
|
+
return result.stdout
|
|
45
|
+
.trim()
|
|
46
|
+
.split('\n')
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.map((entry) => entry.replace(/^package\//, '').replace(/\/$/, ''))
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.sort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assertCleanInventory(inventory) {
|
|
54
|
+
assert.deepEqual(inventory, [...inventory].sort(), 'package inventory must be sorted');
|
|
55
|
+
assert.equal(new Set(inventory).size, inventory.length, 'package inventory must not contain duplicates');
|
|
56
|
+
for (const required of REQUIRED_PAYLOAD) assert.ok(inventory.includes(required), `missing payload: ${required}`);
|
|
57
|
+
for (const entry of inventory) {
|
|
58
|
+
for (const pattern of FORBIDDEN_PAYLOAD) {
|
|
59
|
+
assert.doesNotMatch(entry, pattern, `forbidden generated payload: ${entry}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function installPacked(tarball, root) {
|
|
65
|
+
fs.mkdirSync(root, { recursive: true });
|
|
66
|
+
const result = spawnSync('npm', [
|
|
67
|
+
'install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund',
|
|
68
|
+
'--package-lock=false', '--prefix', root, tarball,
|
|
69
|
+
], { cwd: root, encoding: 'utf8' });
|
|
70
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
71
|
+
const installer = path.join(root, 'node_modules', '@haposoft', 'cafekit', 'bin', 'install.js');
|
|
72
|
+
assert.ok(fs.existsSync(installer), 'npm install must resolve packed package bin');
|
|
73
|
+
return installer;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function runInstaller(installer, root, platforms, lang) {
|
|
77
|
+
const args = ['--platform', platforms.join(','), '--yes'];
|
|
78
|
+
if (lang) args.push('--lang', lang);
|
|
79
|
+
const result = spawnSync(process.execPath, [installer, ...args], {
|
|
80
|
+
cwd: root,
|
|
81
|
+
encoding: 'utf8',
|
|
82
|
+
env: { ...process.env, HOME: path.join(root, 'home'), PATH: '/usr/bin:/bin' },
|
|
83
|
+
});
|
|
84
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function managedBlock(content, start, end) {
|
|
88
|
+
const startIndex = content.indexOf(start);
|
|
89
|
+
const endIndex = content.indexOf(end);
|
|
90
|
+
assert.ok(startIndex >= 0 && endIndex > startIndex, `missing managed block: ${start}`);
|
|
91
|
+
return content.slice(startIndex + start.length, endIndex);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertCombinedInstructionIsolation(root) {
|
|
95
|
+
const agents = fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8');
|
|
96
|
+
const claude = fs.readFileSync(path.join(root, 'CLAUDE.md'), 'utf8');
|
|
97
|
+
const core = managedBlock(agents, '<!-- CAFEKIT CORE START -->', '<!-- CAFEKIT CORE END -->');
|
|
98
|
+
const claudeBlock = managedBlock(claude, '<!-- CAFEKIT CLAUDE START -->', '<!-- CAFEKIT CLAUDE END -->');
|
|
99
|
+
const codexBlock = managedBlock(agents, '<!-- CAFEKIT CODEX START -->', '<!-- CAFEKIT CODEX END -->');
|
|
100
|
+
const opencodeBlock = managedBlock(agents, '<!-- CAFEKIT OPENCODE START -->', '<!-- CAFEKIT OPENCODE END -->');
|
|
101
|
+
|
|
102
|
+
for (const [content, marker] of [
|
|
103
|
+
[agents, '<!-- CAFEKIT CORE START -->'],
|
|
104
|
+
[agents, '<!-- CAFEKIT CODEX START -->'],
|
|
105
|
+
[agents, '<!-- CAFEKIT OPENCODE START -->'],
|
|
106
|
+
[claude, '<!-- CAFEKIT CLAUDE START -->']
|
|
107
|
+
]) assert.equal((content.match(new RegExp(marker, 'g')) || []).length, 1);
|
|
108
|
+
|
|
109
|
+
assert.doesNotMatch(core, /Claude|Codex|OpenCode|\.claude|\.codex|\.opencode|\/hapo:|\$hapo-/i);
|
|
110
|
+
assert.doesNotMatch(claudeBlock, /Codex|OpenCode|\.codex|\.opencode|\$hapo-/i);
|
|
111
|
+
assert.match(codexBlock, /native project instruction surface is root `AGENTS\.md`/);
|
|
112
|
+
assert.match(opencodeBlock, /native project instruction surface is root `AGENTS\.md`/);
|
|
113
|
+
assert.match(agents, /shared-root trade-off is intentional/);
|
|
114
|
+
// H5 ownership/ignore contract
|
|
115
|
+
assert.match(core, /runtime-neutral/i);
|
|
116
|
+
assert.match(core, /fail-safe/i);
|
|
117
|
+
assert.match(codexBlock, /owned by Codex/i);
|
|
118
|
+
assert.match(codexBlock, /ignore this entire Codex block/i);
|
|
119
|
+
assert.match(opencodeBlock, /owned by OpenCode/i);
|
|
120
|
+
assert.match(opencodeBlock, /ignore this entire OpenCode block/i);
|
|
121
|
+
assert.doesNotMatch(core, /\$hapo-|hapo:/i);
|
|
122
|
+
assert.doesNotMatch(claudeBlock, /<!-- CAFEKIT (CODEX|OPENCODE) /);
|
|
123
|
+
}
|
|
124
|
+
function stableInstallSnapshot(root, platforms) {
|
|
125
|
+
const files = ['AGENTS.md', 'CLAUDE.md', '.gitignore'];
|
|
126
|
+
for (const platform of platforms) files.push(RUNTIMES[platform].root);
|
|
127
|
+
const snapshot = {};
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
const absolute = path.join(root, file);
|
|
130
|
+
if (!fs.existsSync(absolute)) continue;
|
|
131
|
+
const walk = (current, relative = '') => {
|
|
132
|
+
const stat = fs.lstatSync(current);
|
|
133
|
+
if (stat.isDirectory()) {
|
|
134
|
+
for (const entry of fs.readdirSync(current).sort()) walk(path.join(current, entry), path.join(relative, entry));
|
|
135
|
+
} else {
|
|
136
|
+
if (path.basename(current) === 'cafekit.json') {
|
|
137
|
+
const metadata = JSON.parse(fs.readFileSync(current, 'utf8'));
|
|
138
|
+
delete metadata.lastInstalledAt;
|
|
139
|
+
snapshot[path.join(file, relative)] = Buffer.from(`${JSON.stringify(metadata, null, 2)}\n`);
|
|
140
|
+
} else {
|
|
141
|
+
snapshot[path.join(file, relative)] = fs.readFileSync(current);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
walk(absolute);
|
|
146
|
+
}
|
|
147
|
+
return snapshot;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function assertHookLanguage(root, platform, lang, sessionId) {
|
|
151
|
+
const runtime = RUNTIMES[platform];
|
|
152
|
+
const runtimeJson = JSON.parse(fs.readFileSync(path.join(root, runtime.root, 'runtime.json'), 'utf8'));
|
|
153
|
+
assert.equal(runtimeJson.locale.responseLanguage, lang || null);
|
|
154
|
+
const rulesPath = path.join(root, runtime.rules);
|
|
155
|
+
assert.ok(fs.existsSync(rulesPath), `installed rules path missing: ${rulesPath}`);
|
|
156
|
+
if (platform === 'opencode') {
|
|
157
|
+
const plugin = fs.readFileSync(rulesPath, 'utf8');
|
|
158
|
+
assert.match(plugin, /if \(respondLang\)/);
|
|
159
|
+
assert.match(plugin, /Respond in \$\{respondLang\}/);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const result = spawnSync(process.execPath, [rulesPath], {
|
|
163
|
+
cwd: root,
|
|
164
|
+
input: JSON.stringify({ cwd: root, session_id: sessionId }),
|
|
165
|
+
encoding: 'utf8',
|
|
166
|
+
});
|
|
167
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
168
|
+
if (lang) assert.match(result.stdout, /Respond in vi/);
|
|
169
|
+
else assert.doesNotMatch(result.stdout, /Respond in/);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function assertInstalledScripts(root, platform) {
|
|
173
|
+
const scripts = path.join(root, RUNTIMES[platform].root, 'scripts');
|
|
174
|
+
const policy = path.join(scripts, 'workflow-policy.cjs');
|
|
175
|
+
const scanner = path.join(scripts, 'scan-staged-secrets.cjs');
|
|
176
|
+
assert.ok(fs.existsSync(policy), `installed policy missing: ${policy}`);
|
|
177
|
+
assert.ok(fs.existsSync(scanner), `installed scanner missing: ${scanner}`);
|
|
178
|
+
|
|
179
|
+
const policyRun = spawnSync(process.execPath, [policy, '--json'], { cwd: root, encoding: 'utf8' });
|
|
180
|
+
assert.equal(policyRun.status, 0, `${policyRun.stdout}\n${policyRun.stderr}`);
|
|
181
|
+
assert.equal(JSON.parse(policyRun.stdout).contract, 'execution-policy');
|
|
182
|
+
|
|
183
|
+
const safe = path.join(root, 'safe.txt');
|
|
184
|
+
fs.writeFileSync(safe, 'mode=safe\n');
|
|
185
|
+
spawnSync('git', ['init', '-q'], { cwd: root });
|
|
186
|
+
spawnSync('git', ['add', 'safe.txt'], { cwd: root });
|
|
187
|
+
const scannerRun = spawnSync(process.execPath, [scanner], { cwd: root, encoding: 'utf8' });
|
|
188
|
+
assert.equal(scannerRun.status, 0, `${scannerRun.stdout}\n${scannerRun.stderr}`);
|
|
189
|
+
assert.match(scannerRun.stdout, /No staged secrets found/);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function assertTransforms(root, platform) {
|
|
193
|
+
const skill = path.join(root, RUNTIMES[platform].root === '.codex' ? '.agents/skills' : `${RUNTIMES[platform].root}/skills`, 'develop', 'SKILL.md');
|
|
194
|
+
const content = fs.readFileSync(skill, 'utf8');
|
|
195
|
+
if (platform === 'codex') {
|
|
196
|
+
assert.match(content, /\$hapo-develop/);
|
|
197
|
+
assert.doesNotMatch(content, /\/hapo:develop/);
|
|
198
|
+
} else {
|
|
199
|
+
assert.match(content, /\/hapo:develop/);
|
|
200
|
+
}
|
|
201
|
+
assert.ok(fs.existsSync(path.join(root, RUNTIMES[platform].root, 'agents')) || platform === 'codex');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
test('npm dry-run inventory is deterministic and preserves runtime payload', () => {
|
|
205
|
+
const first = npmPack(['--dry-run', '--json'], PACKAGE_ROOT);
|
|
206
|
+
const second = npmPack(['--dry-run', '--json'], PACKAGE_ROOT);
|
|
207
|
+
const firstInventory = first.files.map(({ path: filePath }) => filePath).sort();
|
|
208
|
+
const secondInventory = second.files.map(({ path: filePath }) => filePath).sort();
|
|
209
|
+
assert.deepEqual(firstInventory, secondInventory);
|
|
210
|
+
assertCleanInventory(firstInventory);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('packed tarball installer matrix proves locale, transforms, paths, and rerun safety', () => {
|
|
214
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-package-matrix-'));
|
|
215
|
+
const destination = path.join(root, 'pack');
|
|
216
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
217
|
+
try {
|
|
218
|
+
const packed = npmPack(['--pack-destination', destination, '--json'], PACKAGE_ROOT);
|
|
219
|
+
const tarball = path.join(destination, packed.filename);
|
|
220
|
+
assertCleanInventory(packedInventory(tarball));
|
|
221
|
+
|
|
222
|
+
const cases = [
|
|
223
|
+
{ name: 'claude', platforms: ['claude'] },
|
|
224
|
+
{ name: 'codex', platforms: ['codex'] },
|
|
225
|
+
{ name: 'opencode', platforms: ['opencode'] },
|
|
226
|
+
{ name: 'combined', platforms: ['claude', 'codex', 'opencode'] },
|
|
227
|
+
{ name: 'combined-rerun', platforms: ['claude', 'codex', 'opencode'], rerun: true },
|
|
228
|
+
];
|
|
229
|
+
for (const matrixCase of cases) {
|
|
230
|
+
for (const lang of [null, 'vi']) {
|
|
231
|
+
const project = path.join(root, `${matrixCase.name}-${lang ? 'lang-vi' : 'no-lang'}`);
|
|
232
|
+
const installer = installPacked(tarball, project);
|
|
233
|
+
runInstaller(installer, project, matrixCase.platforms, lang);
|
|
234
|
+
for (const platform of matrixCase.platforms) {
|
|
235
|
+
assertHookLanguage(project, platform, lang, `${project}-${matrixCase.name}-${lang || 'none'}`);
|
|
236
|
+
assertInstalledScripts(project, platform);
|
|
237
|
+
assertTransforms(project, platform);
|
|
238
|
+
}
|
|
239
|
+
if (matrixCase.platforms.length === 3) assertCombinedInstructionIsolation(project);
|
|
240
|
+
if (matrixCase.rerun) {
|
|
241
|
+
fs.appendFileSync(path.join(project, 'AGENTS.md'), '\n## User matrix note\nKeep this exact.\n');
|
|
242
|
+
fs.appendFileSync(path.join(project, 'CLAUDE.md'), '\n## User Claude matrix note\nKeep this exact.\n');
|
|
243
|
+
const before = stableInstallSnapshot(project, matrixCase.platforms);
|
|
244
|
+
runInstaller(installer, project, matrixCase.platforms, lang);
|
|
245
|
+
const after = stableInstallSnapshot(project, matrixCase.platforms);
|
|
246
|
+
const changed = [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
247
|
+
.filter((file) => !before[file] || !after[file] || !before[file].equals(after[file]));
|
|
248
|
+
assert.deepEqual(changed, [], `${matrixCase.name} rerun must be byte-idempotent`);
|
|
249
|
+
assertCombinedInstructionIsolation(project);
|
|
250
|
+
assert.match(fs.readFileSync(path.join(project, 'AGENTS.md'), 'utf8'), /User matrix note\nKeep this exact\./);
|
|
251
|
+
assert.match(fs.readFileSync(path.join(project, 'CLAUDE.md'), 'utf8'), /User Claude matrix note\nKeep this exact\./);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
257
|
+
}
|
|
258
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawnSync } = require('node:child_process');
|
|
8
|
+
const { test } = require('node:test');
|
|
9
|
+
|
|
10
|
+
const HELPER = path.join(__dirname, '../../src/claude/scripts/scan-staged-secrets.cjs');
|
|
11
|
+
|
|
12
|
+
function git(root, args) {
|
|
13
|
+
const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
14
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function withRepo(run) {
|
|
18
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cafekit-secret-scan-'));
|
|
19
|
+
try {
|
|
20
|
+
git(root, ['init', '-q']);
|
|
21
|
+
git(root, ['config', 'user.email', 'test@example.invalid']);
|
|
22
|
+
git(root, ['config', 'user.name', 'CafeKit Test']);
|
|
23
|
+
fs.writeFileSync(path.join(root, 'config.ts'), 'export const mode = "safe";\n');
|
|
24
|
+
git(root, ['add', '.']);
|
|
25
|
+
git(root, ['commit', '-qm', 'fixture']);
|
|
26
|
+
return run(root);
|
|
27
|
+
} finally {
|
|
28
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('staged secret scanner maps added source lines and redacts values', () => {
|
|
33
|
+
withRepo((root) => {
|
|
34
|
+
const value = ['sk-live-', '0123456789abcdef0123456789'].join('');
|
|
35
|
+
fs.writeFileSync(
|
|
36
|
+
path.join(root, 'config.ts'),
|
|
37
|
+
`export const mode = "safe";\nexport const OPENAI_API_KEY = "${value}";\n`,
|
|
38
|
+
);
|
|
39
|
+
git(root, ['add', 'config.ts']);
|
|
40
|
+
|
|
41
|
+
const result = spawnSync(process.execPath, [HELPER], { cwd: root, encoding: 'utf8' });
|
|
42
|
+
assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`);
|
|
43
|
+
assert.match(result.stdout, /Blocked: possible secret `OPENAI_API_KEY` at config\.ts:2\. Value not shown\./);
|
|
44
|
+
assert.doesNotMatch(result.stdout, new RegExp(value));
|
|
45
|
+
assert.doesNotMatch(result.stderr, new RegExp(value));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('staged secret scanner handles JSON/YAML names and safe-name exclusions', () => {
|
|
50
|
+
withRepo((root) => {
|
|
51
|
+
const longValue = ['0123456789abcdef', '0123456789abcdef'].join('');
|
|
52
|
+
fs.writeFileSync(
|
|
53
|
+
path.join(root, 'config.yml'),
|
|
54
|
+
[
|
|
55
|
+
'tokenizer: enabled',
|
|
56
|
+
'password_hint_label: "Password"',
|
|
57
|
+
'api_key_file: "./fixtures/key.txt"',
|
|
58
|
+
`apiKey: "${longValue}"`,
|
|
59
|
+
`AWS_SECRET_ACCESS_KEY: "${longValue}"`,
|
|
60
|
+
'example_token: "changeme"',
|
|
61
|
+
'',
|
|
62
|
+
].join('\n'),
|
|
63
|
+
);
|
|
64
|
+
git(root, ['add', 'config.yml']);
|
|
65
|
+
|
|
66
|
+
const result = spawnSync(process.execPath, [HELPER], { cwd: root, encoding: 'utf8' });
|
|
67
|
+
assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`);
|
|
68
|
+
assert.match(result.stdout, /`apiKey` at config\.yml:4/);
|
|
69
|
+
assert.match(result.stdout, /`AWS_SECRET_ACCESS_KEY` at config\.yml:5/);
|
|
70
|
+
assert.doesNotMatch(result.stdout, /tokenizer|password_hint_label|api_key_file|example_token/);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('staged secret scanner maps multiline YAML/JSON values to key lines', () => {
|
|
75
|
+
withRepo((root) => {
|
|
76
|
+
const value = ['sk-live-', 'abcdef0123456789abcdef0123456789'].join('');
|
|
77
|
+
fs.writeFileSync(
|
|
78
|
+
path.join(root, 'multiline.yml'),
|
|
79
|
+
[
|
|
80
|
+
'OPENAI_API_KEY:',
|
|
81
|
+
` "${value}"`,
|
|
82
|
+
'PRIVATE_KEY: |',
|
|
83
|
+
' -----BEGIN PRIVATE KEY-----',
|
|
84
|
+
' redacted-material',
|
|
85
|
+
' -----END PRIVATE KEY-----',
|
|
86
|
+
'public_url: https://example.invalid/docs',
|
|
87
|
+
'password = getPassword()',
|
|
88
|
+
'TOKEN: process.env.TOKEN',
|
|
89
|
+
'',
|
|
90
|
+
].join('\n'),
|
|
91
|
+
);
|
|
92
|
+
git(root, ['add', 'multiline.yml']);
|
|
93
|
+
|
|
94
|
+
const result = spawnSync(process.execPath, [HELPER], { cwd: root, encoding: 'utf8' });
|
|
95
|
+
assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`);
|
|
96
|
+
assert.match(result.stdout, /`OPENAI_API_KEY` at multiline\.yml:1/);
|
|
97
|
+
assert.match(result.stdout, /`PRIVATE_KEY` at multiline\.yml:3/);
|
|
98
|
+
assert.doesNotMatch(result.stdout, /public_url|password|TOKEN/);
|
|
99
|
+
assert.doesNotMatch(result.stdout, new RegExp(value));
|
|
100
|
+
assert.doesNotMatch(result.stdout, /redacted-material/);
|
|
101
|
+
assert.doesNotMatch(result.stderr, new RegExp(value));
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
test('staged secret scanner detects short passwords, PEM, credential URLs, and basic auth', () => {
|
|
107
|
+
withRepo((root) => {
|
|
108
|
+
fs.writeFileSync(
|
|
109
|
+
path.join(root, 'secrets.env'),
|
|
110
|
+
[
|
|
111
|
+
'password="s3cr3t!"',
|
|
112
|
+
'PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\\nredacted\\n-----END RSA PRIVATE KEY-----"',
|
|
113
|
+
'DATABASE_URL="postgres://app:short-pass@db.example.invalid/app"',
|
|
114
|
+
'Authorization="Basic dXNlcjpwYXNz"',
|
|
115
|
+
'tokenizer="not-a-secret"',
|
|
116
|
+
'password_hint="Password"',
|
|
117
|
+
'api_key_file="./fixtures/key.txt"',
|
|
118
|
+
'TOKEN="${TOKEN_FROM_ENV}"',
|
|
119
|
+
'url="https://example.invalid/docs"',
|
|
120
|
+
'',
|
|
121
|
+
].join('\n'),
|
|
122
|
+
);
|
|
123
|
+
git(root, ['add', 'secrets.env']);
|
|
124
|
+
|
|
125
|
+
const result = spawnSync(process.execPath, [HELPER], { cwd: root, encoding: 'utf8' });
|
|
126
|
+
assert.equal(result.status, 1, `${result.stdout}\n${result.stderr}`);
|
|
127
|
+
for (const name of ['password', 'PRIVATE_KEY', 'DATABASE_URL', 'Authorization']) {
|
|
128
|
+
assert.match(result.stdout, new RegExp('`' + name + '` at secrets\\.env:'));
|
|
129
|
+
}
|
|
130
|
+
for (const name of ['tokenizer', 'password_hint', 'api_key_file', 'TOKEN', 'url']) {
|
|
131
|
+
assert.doesNotMatch(result.stdout, new RegExp('`' + name + '`'));
|
|
132
|
+
}
|
|
133
|
+
assert.doesNotMatch(result.stdout, /s3cr3t|short-pass|dXNlcjpwYXNz|redacted/);
|
|
134
|
+
assert.doesNotMatch(result.stderr, /s3cr3t|short-pass|dXNlcjpwYXNz|redacted/);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('staged secret scanner ignores deleted and context lines', () => {
|
|
139
|
+
withRepo((root) => {
|
|
140
|
+
const oldValue = ['sk-old-', '0123456789abcdef0123456789'].join('');
|
|
141
|
+
fs.writeFileSync(
|
|
142
|
+
path.join(root, 'config.ts'),
|
|
143
|
+
`export const OLD_TOKEN = "${oldValue}";\nexport const mode = "safe";\n`,
|
|
144
|
+
);
|
|
145
|
+
git(root, ['add', 'config.ts']);
|
|
146
|
+
git(root, ['commit', '-qm', 'old secret fixture']);
|
|
147
|
+
fs.writeFileSync(
|
|
148
|
+
path.join(root, 'config.ts'),
|
|
149
|
+
'export const mode = "safe";\nexport const replacement = true;\n',
|
|
150
|
+
);
|
|
151
|
+
git(root, ['add', 'config.ts']);
|
|
152
|
+
|
|
153
|
+
const result = spawnSync(process.execPath, [HELPER], { cwd: root, encoding: 'utf8' });
|
|
154
|
+
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
|
155
|
+
assert.match(result.stdout, /No staged secrets found/);
|
|
156
|
+
});
|
|
157
|
+
});
|