@haposoft/cafekit 0.13.3 → 0.14.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/README.md +1 -2
- package/bin/lib/i18n.js +2 -2
- package/bin/lib/manifest.js +15 -0
- package/bin/lib/opencode-install.js +0 -14
- package/bin/phases/claude-runtime.js +4 -1
- package/bin/phases/claude-settings.js +38 -7
- package/package.json +1 -1
- package/src/claude/agents/ui-ux-designer.md +4 -4
- package/src/claude/hooks/__tests__/privacy-block.test.js +79 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +68 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +270 -0
- package/src/claude/hooks/privacy-block.cjs +22 -2
- package/src/claude/hooks/session.cjs +5 -1
- package/src/claude/hooks/spec-gate.cjs +173 -0
- package/src/claude/hooks/spec-state.cjs +10 -17
- package/src/claude/hooks/task-scaffold-guard.cjs +4 -2
- package/src/claude/migration-manifest.json +5 -4
- package/src/claude/rules/skill-domain-routing.md +2 -3
- package/src/claude/rules/skill-workflow-routing.md +2 -3
- package/src/claude/rules/state-sync.md +4 -0
- package/src/claude/runtime.json +4 -0
- package/src/claude/scripts/validate-docs.cjs +4 -7
- package/src/claude/scripts/validate-spec-output.cjs +29 -9
- package/src/claude/settings/settings.json +4 -0
- package/src/claude/skills/ai-multimodal/.env.example +11 -13
- package/src/claude/skills/ai-multimodal/SKILL.md +1 -1
- package/src/claude/skills/ai-multimodal/references/audio-processing.md +25 -27
- package/src/claude/skills/ai-multimodal/references/video-analysis.md +23 -27
- package/src/claude/skills/ai-multimodal/references/vision-understanding.md +26 -26
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +1 -1
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +7 -9
- package/src/claude/skills/brainstorm/SKILL.md +1 -2
- package/src/claude/skills/docx/SKILL.md +1 -1
- package/src/claude/skills/frontend-design/SKILL.md +1 -1
- package/src/claude/skills/frontend-design/references/ai-multimodal-overview.md +4 -4
- package/src/claude/skills/frontend-design/references/analysis-techniques.md +3 -3
- package/src/claude/skills/frontend-design/references/asset-generation.md +2 -2
- package/src/claude/skills/frontend-design/references/design-extraction-overview.md +4 -4
- package/src/claude/skills/frontend-design/references/extraction-best-practices.md +3 -3
- package/src/claude/skills/frontend-design/references/technical-accessibility.md +1 -1
- package/src/claude/skills/frontend-design/references/technical-optimization.md +1 -1
- package/src/claude/skills/frontend-design/references/technical-overview.md +2 -2
- package/src/claude/skills/frontend-design/references/technical-workflows.md +6 -6
- package/src/claude/skills/frontend-design/references/visual-analysis-overview.md +3 -3
- package/src/claude/skills/inspect/SKILL.md +3 -3
- package/src/claude/skills/inspect/references/external-gemini-inspection.md +9 -9
- package/src/claude/skills/pdf/SKILL.md +1 -1
- package/src/claude/skills/pptx/SKILL.md +1 -1
- package/src/claude/skills/specs/SKILL.md +1 -1
- package/src/claude/skills/xlsx/SKILL.md +1 -1
- package/src/opencode/AGENTS.md +1 -2
- package/src/opencode/plugins/session.ts +2 -2
- package/src/opencode/plugins/spec-state.ts +244 -0
- package/src/opencode/plugins/task-scaffold-guard.ts +161 -0
- package/src/claude/scripts/browser-tool.cjs +0 -138
- package/src/claude/skills/generate-graph/LICENSE +0 -21
- package/src/claude/skills/generate-graph/README.md +0 -523
- package/src/claude/skills/generate-graph/SKILL.md +0 -429
- package/src/claude/skills/generate-graph/agentloop-core.svg +0 -101
- package/src/claude/skills/generate-graph/agents/openai.yaml +0 -4
- package/src/claude/skills/generate-graph/assets/samples/sample-style1-flat.png +0 -0
- package/src/claude/skills/generate-graph/assets/samples/sample-style2-dark.png +0 -0
- package/src/claude/skills/generate-graph/assets/samples/sample-style3-blueprint.png +0 -0
- package/src/claude/skills/generate-graph/assets/samples/sample-style4-notion.png +0 -0
- package/src/claude/skills/generate-graph/assets/samples/sample-style5-glass.png +0 -0
- package/src/claude/skills/generate-graph/assets/samples/sample-style6-claude.png +0 -0
- package/src/claude/skills/generate-graph/assets/samples/sample-style7-openai.png +0 -0
- package/src/claude/skills/generate-graph/fixtures/agent-memory-types-style4.json +0 -181
- package/src/claude/skills/generate-graph/fixtures/api-flow-style7.json +0 -40
- package/src/claude/skills/generate-graph/fixtures/mem0-style1.json +0 -297
- package/src/claude/skills/generate-graph/fixtures/microservices-style3.json +0 -64
- package/src/claude/skills/generate-graph/fixtures/multi-agent-style5.json +0 -45
- package/src/claude/skills/generate-graph/fixtures/system-architecture-style6.json +0 -48
- package/src/claude/skills/generate-graph/fixtures/tool-call-style2.json +0 -182
- package/src/claude/skills/generate-graph/package.json +0 -42
- package/src/claude/skills/generate-graph/references/icons.md +0 -281
- package/src/claude/skills/generate-graph/references/style-1-flat-icon.md +0 -108
- package/src/claude/skills/generate-graph/references/style-2-dark-terminal.md +0 -107
- package/src/claude/skills/generate-graph/references/style-3-blueprint.md +0 -113
- package/src/claude/skills/generate-graph/references/style-4-notion-clean.md +0 -94
- package/src/claude/skills/generate-graph/references/style-5-glassmorphism.md +0 -125
- package/src/claude/skills/generate-graph/references/style-6-claude-official.md +0 -209
- package/src/claude/skills/generate-graph/references/style-7-openai.md +0 -215
- package/src/claude/skills/generate-graph/references/style-diagram-matrix.md +0 -135
- package/src/claude/skills/generate-graph/references/svg-layout-best-practices.md +0 -100
- package/src/claude/skills/generate-graph/scripts/generate-diagram.sh +0 -157
- package/src/claude/skills/generate-graph/scripts/generate-from-template.py +0 -1556
- package/src/claude/skills/generate-graph/scripts/test-all-styles.sh +0 -135
- package/src/claude/skills/generate-graph/scripts/validate-svg.sh +0 -292
- package/src/claude/skills/generate-graph/templates/agent-architecture.svg +0 -28
- package/src/claude/skills/generate-graph/templates/architecture.svg +0 -23
- package/src/claude/skills/generate-graph/templates/comparison-matrix.svg +0 -14
- package/src/claude/skills/generate-graph/templates/data-flow.svg +0 -28
- package/src/claude/skills/generate-graph/templates/er-diagram.svg +0 -21
- package/src/claude/skills/generate-graph/templates/flowchart.svg +0 -21
- package/src/claude/skills/generate-graph/templates/sequence.svg +0 -20
- package/src/claude/skills/generate-graph/templates/state-machine.svg +0 -20
- package/src/claude/skills/generate-graph/templates/timeline.svg +0 -19
- package/src/claude/skills/generate-graph/templates/use-case.svg +0 -21
- package/src/claude/skills/impact-analysis/SKILL.md +0 -276
- package/src/claude/skills/impact-analysis/references/change-detection.md +0 -270
- package/src/claude/skills/impact-analysis/references/dependency-scouting.md +0 -338
- package/src/claude/skills/impact-analysis/references/edge-case-identification.md +0 -439
- package/src/claude/skills/impact-analysis/references/industry-techniques.md +0 -695
- package/src/claude/skills/impact-analysis/references/practical-techniques-guide.md +0 -753
- package/src/claude/skills/impact-analysis/references/project-detection.md +0 -704
- package/src/claude/skills/impact-analysis/references/react-native-customization.md +0 -508
- package/src/claude/skills/impact-analysis/references/report-template.md +0 -604
- package/src/claude/skills/impact-analysis/references/test-scenario-generation.md +0 -459
- package/src/claude/skills/impact-analysis/scripts/README.md +0 -476
- package/src/claude/skills/impact-analysis/scripts/ast-analyze.js +0 -403
- package/src/claude/skills/impact-analysis/scripts/calculate-risk.js +0 -475
- package/src/claude/skills/impact-analysis/scripts/find-dependencies.sh +0 -202
- package/src/claude/skills/impact-analysis/scripts/run-analysis.sh +0 -312
package/README.md
CHANGED
|
@@ -111,9 +111,8 @@ CafeKit ships many skills, but the main release surface is:
|
|
|
111
111
|
- `/hapo:docs [--init|--update|--summarize|--reconstruct]`: create project docs or reconstruct as-is system documentation from source code
|
|
112
112
|
- `/hapo:test [scope|--full]`: run verification and return a structured verdict
|
|
113
113
|
- `/hapo:code-review [scope|--pending]`: adversarial review focused on correctness, regressions, and security
|
|
114
|
-
- `/hapo:generate-graph <diagram request>`: generate technical SVG/PNG diagrams
|
|
115
114
|
|
|
116
|
-
Common companion skills bundled in this package include `inspect`, `
|
|
115
|
+
Common companion skills bundled in this package include `inspect`, `research`, `ai-multimodal`, `frontend-development`, `backend-development`, and `react-best-practices`.
|
|
117
116
|
|
|
118
117
|
CafeKit uses rule-based skill routing guidance instead of an automatic prompt-scoring hook. See `.claude/rules/skill-workflow-routing.md`, `.claude/rules/skill-domain-routing.md`, or run:
|
|
119
118
|
|
package/bin/lib/i18n.js
CHANGED
|
@@ -366,11 +366,11 @@ const MESSAGES = {
|
|
|
366
366
|
}
|
|
367
367
|
};
|
|
368
368
|
|
|
369
|
-
/** Normalize a language code to a supported one; unknown → '
|
|
369
|
+
/** Normalize a language code to a supported one; unknown → 'en'. */
|
|
370
370
|
function resolveLang(code) {
|
|
371
371
|
if (!code) return 'en';
|
|
372
372
|
const lc = String(code).toLowerCase().slice(0, 2);
|
|
373
|
-
return SUPPORTED.includes(lc) ? lc : '
|
|
373
|
+
return SUPPORTED.includes(lc) ? lc : 'en';
|
|
374
374
|
}
|
|
375
375
|
|
|
376
376
|
/** Build a translator. Falls back to English per-key, then the key itself. */
|
package/bin/lib/manifest.js
CHANGED
|
@@ -105,6 +105,21 @@ function createTracker(platformFolder, version) {
|
|
|
105
105
|
this._pruned.add(key);
|
|
106
106
|
},
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Prune every tracked key under a directory prefix (e.g. "skills/foo/").
|
|
110
|
+
* Marks prior-manifest keys with the same prefix for deletion on write().
|
|
111
|
+
*/
|
|
112
|
+
prunePrefix(relPrefix) {
|
|
113
|
+
const prefix = relPrefix.replace(/\\/g, '/');
|
|
114
|
+
for (const key of Object.keys(files)) {
|
|
115
|
+
if (key.startsWith(prefix)) delete files[key];
|
|
116
|
+
}
|
|
117
|
+
const prior = read(platformFolder);
|
|
118
|
+
for (const key of Object.keys(prior.files || {})) {
|
|
119
|
+
if (key.startsWith(prefix)) this._pruned.add(key);
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
|
|
108
123
|
/** Recorded hash for a relPath written earlier this run, or null. */
|
|
109
124
|
recorded(rel) {
|
|
110
125
|
const key = rel.replace(/\\/g, '/');
|
|
@@ -94,13 +94,6 @@ const OPENCODE_COMMAND_TEMPLATES = [
|
|
|
94
94
|
subtask: true,
|
|
95
95
|
description: 'Inspect a codebase, artifact, or external context before planning.'
|
|
96
96
|
},
|
|
97
|
-
{
|
|
98
|
-
fileName: 'generate-graph.md',
|
|
99
|
-
skillName: 'generate-graph',
|
|
100
|
-
agent: 'god-developer',
|
|
101
|
-
subtask: true,
|
|
102
|
-
description: 'Generate technical diagrams from project context.'
|
|
103
|
-
},
|
|
104
97
|
// Domain skills — wrappers so users can invoke them directly via slash
|
|
105
98
|
// commands. Each delegates to the matching skill under .opencode/skills/.
|
|
106
99
|
{
|
|
@@ -166,13 +159,6 @@ const OPENCODE_COMMAND_TEMPLATES = [
|
|
|
166
159
|
subtask: true,
|
|
167
160
|
description: 'Run git workflows: branch, commit, tag, push, or release prep.'
|
|
168
161
|
},
|
|
169
|
-
{
|
|
170
|
-
fileName: 'impact-analysis.md',
|
|
171
|
-
skillName: 'impact-analysis',
|
|
172
|
-
agent: 'inspector',
|
|
173
|
-
subtask: true,
|
|
174
|
-
description: 'Assess affected files, blast radius, and side effects of a change.'
|
|
175
|
-
},
|
|
176
162
|
{
|
|
177
163
|
fileName: 'mobile-development.md',
|
|
178
164
|
skillName: 'mobile-development',
|
|
@@ -78,11 +78,14 @@ function removeObsoleteClaudeRuntimeFiles(ctx, platformKey) {
|
|
|
78
78
|
const targetPath = path.join(targetBase, relPath);
|
|
79
79
|
if (!fs.existsSync(targetPath)) return;
|
|
80
80
|
|
|
81
|
+
const isDir = fs.statSync(targetPath).isDirectory();
|
|
81
82
|
if (!ctx.dryRun) {
|
|
82
|
-
fs.rmSync(targetPath, { force: true });
|
|
83
|
+
fs.rmSync(targetPath, { force: true, recursive: isDir });
|
|
83
84
|
// Remove the entry from the manifest so it doesn't linger as a zombie.
|
|
84
85
|
if (ctx.trackers && ctx.trackers.claude) {
|
|
85
86
|
ctx.trackers.claude.prune(relPath);
|
|
87
|
+
// Directories: prune every tracked file underneath as well.
|
|
88
|
+
if (isDir) ctx.trackers.claude.prunePrefix(`${relPath}/`);
|
|
86
89
|
}
|
|
87
90
|
}
|
|
88
91
|
ctx.ui.detail(` ↻ ${ctx.dryRun ? '[dry-run] ' : ''}Removed obsolete runtime: ${relPath}`);
|
|
@@ -85,7 +85,16 @@ function mergeClaudeSettings(ctx, platformKey) {
|
|
|
85
85
|
const managedSettings = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
|
|
86
86
|
let existingSettings = {};
|
|
87
87
|
if (fs.existsSync(targetPath)) {
|
|
88
|
-
|
|
88
|
+
// A malformed user settings.json must not abort the whole install: skip the
|
|
89
|
+
// settings merge (never overwrite the user's file blind) and tell them why.
|
|
90
|
+
try {
|
|
91
|
+
existingSettings = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
|
|
92
|
+
} catch (e) {
|
|
93
|
+
ctx.ui.warn(`Settings: ${targetPath} is not valid JSON (${e.message}). ` +
|
|
94
|
+
'Skipping settings merge — fix the file and re-run the installer.');
|
|
95
|
+
ctx.results.errors++;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
const mergedSettings = pruneObsoleteSettingsHooks({ ...existingSettings }, ctx);
|
|
@@ -100,7 +109,10 @@ function mergeClaudeSettings(ctx, platformKey) {
|
|
|
100
109
|
}
|
|
101
110
|
}
|
|
102
111
|
|
|
103
|
-
// hooks:
|
|
112
|
+
// hooks: merge managed hooks per command (not per entry). An entry is keyed
|
|
113
|
+
// by its matcher; commands missing from the matching entry are appended, so
|
|
114
|
+
// a new command added to an existing matcher entry is not swallowed on
|
|
115
|
+
// upgrade (the old dedupe only looked at each entry's FIRST command).
|
|
104
116
|
if (managedSettings.hooks) {
|
|
105
117
|
mergedSettings.hooks = mergedSettings.hooks || {};
|
|
106
118
|
Object.keys(managedSettings.hooks).forEach((eventName) => {
|
|
@@ -108,14 +120,33 @@ function mergeClaudeSettings(ctx, platformKey) {
|
|
|
108
120
|
const existingHooks = mergedSettings.hooks[eventName] || [];
|
|
109
121
|
const mergedHooks = [...existingHooks];
|
|
110
122
|
|
|
123
|
+
// Commands already registered anywhere under this event (any matcher):
|
|
124
|
+
// a user may have moved a hook to another entry — never re-add it.
|
|
125
|
+
const eventCommands = new Set(
|
|
126
|
+
mergedHooks.flatMap((entry) => (entry?.hooks || []).map((h) => h?.command)).filter(Boolean)
|
|
127
|
+
);
|
|
128
|
+
|
|
111
129
|
managedHooks.forEach((managedHook) => {
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
130
|
+
const managedMatcher = managedHook.matcher || '';
|
|
131
|
+
const managedCommands = (managedHook.hooks || [])
|
|
132
|
+
.filter((h) => h?.command && !eventCommands.has(h.command));
|
|
133
|
+
if (managedCommands.length === 0) return;
|
|
134
|
+
|
|
135
|
+
const target = mergedHooks.find((entry) =>
|
|
136
|
+
(entry?.matcher || '') === managedMatcher && Array.isArray(entry?.hooks));
|
|
137
|
+
|
|
138
|
+
if (!target) {
|
|
139
|
+
mergedHooks.push({ ...managedHook, hooks: managedCommands });
|
|
140
|
+
managedCommands.forEach((h) => eventCommands.add(h.command));
|
|
117
141
|
ctx.ui.detail(` ✓ ${ctx.dryRun ? '[dry-run] ' : ''}Settings: hook ${eventName} merged`);
|
|
142
|
+
return;
|
|
118
143
|
}
|
|
144
|
+
|
|
145
|
+
managedCommands.forEach((hook) => {
|
|
146
|
+
target.hooks.push(hook);
|
|
147
|
+
eventCommands.add(hook.command);
|
|
148
|
+
});
|
|
149
|
+
ctx.ui.detail(` ✓ ${ctx.dryRun ? '[dry-run] ' : ''}Settings: hook ${eventName} merged (+${managedCommands.length} command${managedCommands.length > 1 ? 's' : ''})`);
|
|
119
150
|
});
|
|
120
151
|
|
|
121
152
|
mergedSettings.hooks[eventName] = mergedHooks;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haposoft/cafekit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Claude Code-first spec-driven workflow for AI coding assistants. Bundles CafeKit hapo: skills, runtime hooks, agents, and installer scaffolding.",
|
|
5
5
|
"author": "Haposoft <nghialt@haposoft.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,10 +25,10 @@ You are an award-caliber UI/UX designer. You merge aesthetic excellence with eng
|
|
|
25
25
|
### Phase 1: Research & Trend Scouting
|
|
26
26
|
- Execute explicit searches using the bundled `ui-ux-pro-max` toolkit:
|
|
27
27
|
```bash
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
.claude/skills/.venv/bin/python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<product-type>" --domain product
|
|
29
|
+
.claude/skills/.venv/bin/python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<style-keywords>" --domain style
|
|
30
|
+
.claude/skills/.venv/bin/python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<mood>" --domain typography
|
|
31
|
+
.claude/skills/.venv/bin/python3 .claude/skills/ui-ux-pro-max/scripts/search.py "<industry>" --domain color
|
|
32
32
|
```
|
|
33
33
|
- Study current design trends sourced from Dribbble, Awwwards, Mobbin via the python extractor outputs.
|
|
34
34
|
- Review existing `docs/design-guidelines.md` if it exists.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Behavioral tests for privacy-block.cjs. The hook is run as a real subprocess:
|
|
4
|
+
// a PreToolUse payload is piped to stdin and the exit code is asserted
|
|
5
|
+
// (0 = allow, 2 = block). Covers the symlink-bypass hardening.
|
|
6
|
+
|
|
7
|
+
const { test } = require('node:test');
|
|
8
|
+
const assert = require('node:assert');
|
|
9
|
+
const { spawnSync } = require('node:child_process');
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
const HOOK = path.join(__dirname, '..', 'privacy-block.cjs');
|
|
15
|
+
|
|
16
|
+
/** Run the hook with a tool payload; return { code, stderr }. */
|
|
17
|
+
function runHook(payload, cwd) {
|
|
18
|
+
const res = spawnSync(process.execPath, [HOOK], {
|
|
19
|
+
input: JSON.stringify({ ...payload, cwd: cwd || payload.cwd }),
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
});
|
|
22
|
+
return { code: res.status, stderr: res.stderr || '' };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function tmpDir() {
|
|
26
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'ck-privacy-'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test('blocks a direct Read of .env (exit 2)', () => {
|
|
30
|
+
const dir = tmpDir();
|
|
31
|
+
try {
|
|
32
|
+
const env = path.join(dir, '.env');
|
|
33
|
+
fs.writeFileSync(env, 'SECRET=1');
|
|
34
|
+
const { code } = runHook({ tool_name: 'Read', tool_input: { file_path: env } }, dir);
|
|
35
|
+
assert.strictEqual(code, 2);
|
|
36
|
+
} finally {
|
|
37
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('blocks a symlink whose target is .env (regression: symlink bypass)', () => {
|
|
42
|
+
const dir = tmpDir();
|
|
43
|
+
try {
|
|
44
|
+
const env = path.join(dir, '.env');
|
|
45
|
+
fs.writeFileSync(env, 'SECRET=1');
|
|
46
|
+
const link = path.join(dir, 'notes.txt'); // harmless-looking name
|
|
47
|
+
fs.symlinkSync(env, link);
|
|
48
|
+
const { code } = runHook({ tool_name: 'Read', tool_input: { file_path: link } }, dir);
|
|
49
|
+
assert.strictEqual(code, 2, 'symlink to .env must be blocked, not allowed');
|
|
50
|
+
} finally {
|
|
51
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('allows a normal non-sensitive file (exit 0)', () => {
|
|
56
|
+
const dir = tmpDir();
|
|
57
|
+
try {
|
|
58
|
+
const f = path.join(dir, 'README.md');
|
|
59
|
+
fs.writeFileSync(f, '# hi');
|
|
60
|
+
const { code } = runHook({ tool_name: 'Read', tool_input: { file_path: f } }, dir);
|
|
61
|
+
assert.strictEqual(code, 0);
|
|
62
|
+
} finally {
|
|
63
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('allows a symlink to an exempt .env.example (exemption still wins)', () => {
|
|
68
|
+
const dir = tmpDir();
|
|
69
|
+
try {
|
|
70
|
+
const example = path.join(dir, '.env.example');
|
|
71
|
+
fs.writeFileSync(example, 'SECRET=');
|
|
72
|
+
const link = path.join(dir, 'sample.txt');
|
|
73
|
+
fs.symlinkSync(example, link);
|
|
74
|
+
const { code } = runHook({ tool_name: 'Read', tool_input: { file_path: link } }, dir);
|
|
75
|
+
assert.strictEqual(code, 0, 'symlink to an exempt file must stay allowed');
|
|
76
|
+
} finally {
|
|
77
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Behavioral test for session.cjs writeEnv escaping. A git branch name is an
|
|
4
|
+
// attacker-influenced value that flows into GIT_BRANCH in CLAUDE_ENV_FILE. If
|
|
5
|
+
// the value is not escaped, sourcing that env file expands `$HOME` and executes
|
|
6
|
+
// the `pwd` command substitution. We build a repo whose branch carries both
|
|
7
|
+
// payloads (space-free, since git bans spaces in refs), run the hook, then
|
|
8
|
+
// source the produced env file and assert GIT_BRANCH read back is the exact
|
|
9
|
+
// literal branch name — nothing expanded or executed.
|
|
10
|
+
|
|
11
|
+
const { test } = require('node:test');
|
|
12
|
+
const assert = require('node:assert');
|
|
13
|
+
const { spawnSync } = require('node:child_process');
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const path = require('node:path');
|
|
17
|
+
|
|
18
|
+
const HOOK = path.join(__dirname, '..', 'session.cjs');
|
|
19
|
+
|
|
20
|
+
function git(args, cwd) {
|
|
21
|
+
const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
22
|
+
if (res.status !== 0) {
|
|
23
|
+
throw new Error(`git ${args.join(' ')} failed: ${res.stderr}`);
|
|
24
|
+
}
|
|
25
|
+
return res.stdout.trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('session.cjs escapes shell metacharacters in GIT_BRANCH (no injection when sourced)', () => {
|
|
29
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ck-session-'));
|
|
30
|
+
const envFile = path.join(dir, 'env.sh');
|
|
31
|
+
try {
|
|
32
|
+
// Branch name carries both a $HOME expansion and a `pwd` command
|
|
33
|
+
// substitution. Both are space-free so git accepts them as a valid ref.
|
|
34
|
+
const payloadBranch = 'evil$HOME-x`pwd`';
|
|
35
|
+
git(['init', '-q'], dir);
|
|
36
|
+
git(['config', 'user.email', 't@t.co'], dir);
|
|
37
|
+
git(['config', 'user.name', 't'], dir);
|
|
38
|
+
git(['commit', '-q', '--allow-empty', '-m', 'init'], dir);
|
|
39
|
+
git(['checkout', '-q', '-b', payloadBranch], dir);
|
|
40
|
+
|
|
41
|
+
// Run the hook exactly as Claude Code would (SessionStart on stdin).
|
|
42
|
+
const res = spawnSync(process.execPath, [HOOK], {
|
|
43
|
+
cwd: dir,
|
|
44
|
+
input: JSON.stringify({ source: 'startup' }),
|
|
45
|
+
encoding: 'utf8',
|
|
46
|
+
env: { ...process.env, CLAUDE_ENV_FILE: envFile },
|
|
47
|
+
});
|
|
48
|
+
assert.strictEqual(res.status, 0, 'hook must exit 0 (fail-open)');
|
|
49
|
+
assert.ok(fs.existsSync(envFile), 'env file must be written');
|
|
50
|
+
|
|
51
|
+
const envText = fs.readFileSync(envFile, 'utf8');
|
|
52
|
+
const branchLine = envText.split('\n').find((l) => l.startsWith('export GIT_BRANCH='));
|
|
53
|
+
assert.ok(branchLine, 'GIT_BRANCH must be present');
|
|
54
|
+
assert.ok(branchLine.includes('\\$') && branchLine.includes('\\`'), 'both $ and backtick must be escaped');
|
|
55
|
+
|
|
56
|
+
// The real proof: source the env file, then echo the value back. It must
|
|
57
|
+
// equal the literal branch name — no $HOME expansion, no pwd execution.
|
|
58
|
+
const sourced = spawnSync('sh', ['-c', `. "${envFile}" && printf '%s' "$GIT_BRANCH"`], { encoding: 'utf8' });
|
|
59
|
+
assert.strictEqual(sourced.status, 0, 'sourcing env file should succeed');
|
|
60
|
+
assert.strictEqual(
|
|
61
|
+
sourced.stdout,
|
|
62
|
+
payloadBranch,
|
|
63
|
+
'GIT_BRANCH must read back as the exact literal branch (no expansion/execution)',
|
|
64
|
+
);
|
|
65
|
+
} finally {
|
|
66
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Behavioral tests for spec-gate.cjs (Stop completion gate). The hook is run as
|
|
4
|
+
// a real subprocess: a Stop payload is piped to stdin; we assert exit code and
|
|
5
|
+
// whether stdout carries {"decision":"block",...}. Cache lives under the hook's
|
|
6
|
+
// own .logs/ dir (shared), so each test seeds/clears it deliberately.
|
|
7
|
+
|
|
8
|
+
const { test, beforeEach, after } = require('node:test');
|
|
9
|
+
const assert = require('node:assert');
|
|
10
|
+
const { spawnSync } = require('node:child_process');
|
|
11
|
+
const fs = require('node:fs');
|
|
12
|
+
const os = require('node:os');
|
|
13
|
+
const path = require('node:path');
|
|
14
|
+
|
|
15
|
+
const HOOK = path.join(__dirname, '..', 'spec-gate.cjs');
|
|
16
|
+
const CACHE = path.join(__dirname, '..', '.logs', 'spec-gate-last.json');
|
|
17
|
+
const FEATURE = 'demo';
|
|
18
|
+
const TASK_REL = 'tasks/task-R0-01-x.md';
|
|
19
|
+
|
|
20
|
+
function runHook(payload, cwd) {
|
|
21
|
+
const projectRoot = cwd || payload.cwd;
|
|
22
|
+
// PROJECT_ROOT wins over cwd in the hook; pin it to the fixture so a host
|
|
23
|
+
// monorepo's real specs/ cannot leak into the test (e.g. active rtk-* specs).
|
|
24
|
+
const env = { ...process.env, PROJECT_ROOT: projectRoot };
|
|
25
|
+
const res = spawnSync(process.execPath, [HOOK], {
|
|
26
|
+
input: JSON.stringify({
|
|
27
|
+
hook_event_name: 'Stop',
|
|
28
|
+
session_id: 'test',
|
|
29
|
+
transcript_path: '/tmp/t',
|
|
30
|
+
stop_hook_active: false,
|
|
31
|
+
...payload,
|
|
32
|
+
cwd: projectRoot,
|
|
33
|
+
}),
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
env,
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
code: res.status,
|
|
39
|
+
stdout: (res.stdout || '').trim(),
|
|
40
|
+
stderr: res.stderr || '',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function tmpDir() {
|
|
45
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'ck-spec-gate-'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function clearCache() {
|
|
49
|
+
try { fs.unlinkSync(CACHE); } catch { /* absent */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function seedCache(featureMap) {
|
|
53
|
+
fs.mkdirSync(path.dirname(CACHE), { recursive: true });
|
|
54
|
+
fs.writeFileSync(CACHE, JSON.stringify(featureMap));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readCache() {
|
|
58
|
+
return JSON.parse(fs.readFileSync(CACHE, 'utf8'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a minimal active-spec fixture under dir.
|
|
63
|
+
* @param {object} opts
|
|
64
|
+
* @param {string} [opts.taskStatus='done']
|
|
65
|
+
* @param {string|null} [opts.completed_at='2026-07-01T00:00:00Z']
|
|
66
|
+
* @param {string} [opts.mdStatus='done']
|
|
67
|
+
* @param {'valid'|'missing-evidence'|'placeholder'|'none'} [opts.evidence='valid']
|
|
68
|
+
* @param {object|null} [opts.runtime] — if set, write .claude/runtime.json
|
|
69
|
+
*/
|
|
70
|
+
function makeFixture(opts = {}) {
|
|
71
|
+
const {
|
|
72
|
+
taskStatus = 'done',
|
|
73
|
+
completed_at = '2026-07-01T00:00:00Z',
|
|
74
|
+
mdStatus = 'done',
|
|
75
|
+
evidence = 'valid',
|
|
76
|
+
runtime = null,
|
|
77
|
+
} = opts;
|
|
78
|
+
const dir = tmpDir();
|
|
79
|
+
const featureDir = path.join(dir, 'specs', FEATURE);
|
|
80
|
+
const tasksDir = path.join(featureDir, 'tasks');
|
|
81
|
+
fs.mkdirSync(tasksDir, { recursive: true });
|
|
82
|
+
|
|
83
|
+
const entry = { status: taskStatus };
|
|
84
|
+
if (completed_at !== null) entry.completed_at = completed_at;
|
|
85
|
+
|
|
86
|
+
fs.writeFileSync(
|
|
87
|
+
path.join(featureDir, 'spec.json'),
|
|
88
|
+
JSON.stringify({
|
|
89
|
+
status: 'in_progress',
|
|
90
|
+
current_phase: 'implementation',
|
|
91
|
+
task_registry: { [TASK_REL]: entry },
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
let evidenceBlock = '';
|
|
96
|
+
if (evidence === 'valid') {
|
|
97
|
+
evidenceBlock = [
|
|
98
|
+
'## Evidence',
|
|
99
|
+
'',
|
|
100
|
+
'```',
|
|
101
|
+
'npm test',
|
|
102
|
+
'PASS: 10 tests',
|
|
103
|
+
'```',
|
|
104
|
+
'',
|
|
105
|
+
].join('\n');
|
|
106
|
+
} else if (evidence === 'placeholder') {
|
|
107
|
+
evidenceBlock = [
|
|
108
|
+
'## Evidence',
|
|
109
|
+
'',
|
|
110
|
+
'Command: `{{TYPECHECK / TEST COMMAND}}`',
|
|
111
|
+
'Expected: {{What proves success}}',
|
|
112
|
+
'',
|
|
113
|
+
].join('\n');
|
|
114
|
+
} else if (evidence === 'missing-evidence') {
|
|
115
|
+
evidenceBlock = '## Risk Assessment\n\nNone.\n';
|
|
116
|
+
} else {
|
|
117
|
+
evidenceBlock = '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
fs.writeFileSync(
|
|
121
|
+
path.join(dir, 'specs', FEATURE, TASK_REL),
|
|
122
|
+
[
|
|
123
|
+
`# Task R0-01: example`,
|
|
124
|
+
'',
|
|
125
|
+
`**Status:** ${mdStatus}`,
|
|
126
|
+
'',
|
|
127
|
+
evidenceBlock,
|
|
128
|
+
].join('\n'),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (runtime) {
|
|
132
|
+
fs.mkdirSync(path.join(dir, '.claude'), { recursive: true });
|
|
133
|
+
fs.writeFileSync(
|
|
134
|
+
path.join(dir, '.claude', 'runtime.json'),
|
|
135
|
+
JSON.stringify(runtime),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return dir;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseBlock(stdout) {
|
|
143
|
+
if (!stdout) return null;
|
|
144
|
+
// Hook may print only the JSON line.
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(stdout);
|
|
147
|
+
} catch {
|
|
148
|
+
const line = stdout.split('\n').find((l) => l.includes('"decision"'));
|
|
149
|
+
if (!line) return null;
|
|
150
|
+
return JSON.parse(line);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
beforeEach(() => {
|
|
155
|
+
clearCache();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
after(() => {
|
|
159
|
+
clearCache();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('1. newly-done task WITHOUT receipt → block, reason names task path', () => {
|
|
163
|
+
const dir = makeFixture({
|
|
164
|
+
evidence: 'missing-evidence',
|
|
165
|
+
completed_at: '2026-07-01T00:00:00Z',
|
|
166
|
+
});
|
|
167
|
+
try {
|
|
168
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
169
|
+
const { code, stdout } = runHook({}, dir);
|
|
170
|
+
assert.strictEqual(code, 0);
|
|
171
|
+
const body = parseBlock(stdout);
|
|
172
|
+
assert.ok(body, 'expected block JSON on stdout');
|
|
173
|
+
assert.strictEqual(body.decision, 'block');
|
|
174
|
+
assert.ok(
|
|
175
|
+
body.reason.includes(TASK_REL),
|
|
176
|
+
`reason must name task path; got: ${body.reason}`,
|
|
177
|
+
);
|
|
178
|
+
} finally {
|
|
179
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('2. newly-done task WITH valid receipt → no block, cache updated', () => {
|
|
184
|
+
const dir = makeFixture({ evidence: 'valid' });
|
|
185
|
+
try {
|
|
186
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
187
|
+
const { code, stdout } = runHook({}, dir);
|
|
188
|
+
assert.strictEqual(code, 0);
|
|
189
|
+
assert.strictEqual(stdout, '', 'valid receipt must not produce block output');
|
|
190
|
+
const cache = readCache();
|
|
191
|
+
assert.strictEqual(cache[FEATURE][TASK_REL], 'done');
|
|
192
|
+
} finally {
|
|
193
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('3. no active spec → exit 0 silent', () => {
|
|
198
|
+
const dir = tmpDir();
|
|
199
|
+
try {
|
|
200
|
+
fs.mkdirSync(path.join(dir, 'specs'), { recursive: true });
|
|
201
|
+
// No in_progress spec.
|
|
202
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
203
|
+
const { code, stdout } = runHook({}, dir);
|
|
204
|
+
assert.strictEqual(code, 0);
|
|
205
|
+
assert.strictEqual(stdout, '');
|
|
206
|
+
} finally {
|
|
207
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('4. stop_hook_active: true → exit 0 silent even with violating task', () => {
|
|
212
|
+
const dir = makeFixture({ evidence: 'missing-evidence' });
|
|
213
|
+
try {
|
|
214
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
215
|
+
const { code, stdout } = runHook({ stop_hook_active: true }, dir);
|
|
216
|
+
assert.strictEqual(code, 0);
|
|
217
|
+
assert.strictEqual(stdout, '');
|
|
218
|
+
} finally {
|
|
219
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('5. first run (no cache) with done-without-receipt → exit 0, cache created', () => {
|
|
224
|
+
const dir = makeFixture({ evidence: 'missing-evidence' });
|
|
225
|
+
try {
|
|
226
|
+
clearCache();
|
|
227
|
+
assert.ok(!fs.existsSync(CACHE), 'cache must be absent for first-run');
|
|
228
|
+
const { code, stdout } = runHook({}, dir);
|
|
229
|
+
assert.strictEqual(code, 0);
|
|
230
|
+
assert.strictEqual(stdout, '', 'first run must never block');
|
|
231
|
+
assert.ok(fs.existsSync(CACHE), 'cache file must be created');
|
|
232
|
+
const cache = readCache();
|
|
233
|
+
assert.strictEqual(cache[FEATURE][TASK_REL], 'done');
|
|
234
|
+
} finally {
|
|
235
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('6. runtime.json spec.completion_gate: false → exit 0 silent', () => {
|
|
240
|
+
const dir = makeFixture({
|
|
241
|
+
evidence: 'missing-evidence',
|
|
242
|
+
runtime: { spec: { completion_gate: false } },
|
|
243
|
+
});
|
|
244
|
+
try {
|
|
245
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
246
|
+
const { code, stdout } = runHook({}, dir);
|
|
247
|
+
assert.strictEqual(code, 0);
|
|
248
|
+
assert.strictEqual(stdout, '');
|
|
249
|
+
} finally {
|
|
250
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('7. Evidence with {{...}} placeholder → blocked (check c)', () => {
|
|
255
|
+
const dir = makeFixture({ evidence: 'placeholder' });
|
|
256
|
+
try {
|
|
257
|
+
seedCache({ [FEATURE]: { [TASK_REL]: 'pending' } });
|
|
258
|
+
const { code, stdout } = runHook({}, dir);
|
|
259
|
+
assert.strictEqual(code, 0);
|
|
260
|
+
const body = parseBlock(stdout);
|
|
261
|
+
assert.ok(body, 'expected block JSON on stdout');
|
|
262
|
+
assert.strictEqual(body.decision, 'block');
|
|
263
|
+
assert.ok(
|
|
264
|
+
body.reason.includes(TASK_REL) && /\bc\b/.test(body.reason),
|
|
265
|
+
`reason must cite task path and check c; got: ${body.reason}`,
|
|
266
|
+
);
|
|
267
|
+
} finally {
|
|
268
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
@@ -60,6 +60,20 @@ try {
|
|
|
60
60
|
return RESTRICTED_PATTERNS.some((rule) => rule.test(base) || rule.test(filePath));
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Resolve a symlink to its real target so a harmless-looking name that points
|
|
65
|
+
* at a sensitive file cannot slip through the basename check. Returns null
|
|
66
|
+
* when the path cannot be resolved (missing file, broken link) — fail-open to
|
|
67
|
+
* the original-path check in that case.
|
|
68
|
+
*/
|
|
69
|
+
function resolveTarget(filePath) {
|
|
70
|
+
try {
|
|
71
|
+
return fs.realpathSync(filePath);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
function extractBashPaths(command) {
|
|
64
78
|
const paths = [];
|
|
65
79
|
const regex = /(?:cat|less|more|head|tail|source|\.)\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
|
|
@@ -146,8 +160,14 @@ try {
|
|
|
146
160
|
if (!paths.length) process.exit(0);
|
|
147
161
|
|
|
148
162
|
for (const filePath of paths) {
|
|
149
|
-
|
|
150
|
-
|
|
163
|
+
// Check the requested path and, when it is a symlink, its real target.
|
|
164
|
+
const target = resolveTarget(filePath);
|
|
165
|
+
const candidates = target && target !== filePath ? [filePath, target] : [filePath];
|
|
166
|
+
|
|
167
|
+
// An exemption on either name wins (e.g. a symlink to .env.example).
|
|
168
|
+
if (candidates.some(isSafe)) continue;
|
|
169
|
+
// Sensitive if the requested path OR its symlink target is sensitive.
|
|
170
|
+
if (!candidates.some(isSensitive)) continue;
|
|
151
171
|
|
|
152
172
|
if (toolName === 'Bash') {
|
|
153
173
|
console.error(`WARN: Privacy-sensitive file access via bash allowed for approved follow-up: ${path.basename(filePath)}`);
|
|
@@ -31,7 +31,11 @@ try {
|
|
|
31
31
|
function writeEnv(file, key, value) {
|
|
32
32
|
if (!file) return;
|
|
33
33
|
try {
|
|
34
|
-
|
|
34
|
+
// Escape every char that stays special inside a double-quoted shell
|
|
35
|
+
// string: backslash, dollar, backtick, and the closing quote. Escaping
|
|
36
|
+
// only `"` left `$(...)`/backticks live, so a value sourced from repo
|
|
37
|
+
// content (branch, package name) could execute when the env file is sourced.
|
|
38
|
+
const safe = String(value ?? '').replace(/([\\$`"])/g, '\\$1');
|
|
35
39
|
fs.appendFileSync(file, `export ${key}="${safe}"\n`);
|
|
36
40
|
} catch { /* fail-open */ }
|
|
37
41
|
}
|