@haposoft/cafekit 0.13.4 → 0.14.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.
Files changed (114) hide show
  1. package/README.md +1 -2
  2. package/bin/lib/i18n.js +2 -2
  3. package/bin/lib/manifest.js +15 -0
  4. package/bin/lib/opencode-install.js +0 -14
  5. package/bin/phases/claude-runtime.js +4 -1
  6. package/bin/phases/claude-settings.js +38 -7
  7. package/package.json +1 -1
  8. package/src/claude/agents/ui-ux-designer.md +4 -4
  9. package/src/claude/hooks/__tests__/privacy-block.test.js +79 -0
  10. package/src/claude/hooks/__tests__/session-writeenv.test.js +68 -0
  11. package/src/claude/hooks/__tests__/spec-gate.test.js +270 -0
  12. package/src/claude/hooks/privacy-block.cjs +22 -2
  13. package/src/claude/hooks/session.cjs +5 -1
  14. package/src/claude/hooks/spec-gate.cjs +173 -0
  15. package/src/claude/hooks/spec-state.cjs +10 -17
  16. package/src/claude/hooks/task-scaffold-guard.cjs +4 -2
  17. package/src/claude/migration-manifest.json +5 -4
  18. package/src/claude/rules/skill-domain-routing.md +2 -3
  19. package/src/claude/rules/skill-workflow-routing.md +2 -3
  20. package/src/claude/rules/state-sync.md +4 -0
  21. package/src/claude/runtime.json +4 -0
  22. package/src/claude/scripts/validate-docs.cjs +4 -7
  23. package/src/claude/scripts/validate-spec-output.cjs +29 -9
  24. package/src/claude/settings/settings.json +4 -0
  25. package/src/claude/skills/ai-multimodal/.env.example +11 -13
  26. package/src/claude/skills/ai-multimodal/SKILL.md +1 -1
  27. package/src/claude/skills/ai-multimodal/references/audio-processing.md +25 -27
  28. package/src/claude/skills/ai-multimodal/references/video-analysis.md +23 -27
  29. package/src/claude/skills/ai-multimodal/references/vision-understanding.md +26 -26
  30. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +1 -1
  31. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +7 -9
  32. package/src/claude/skills/brainstorm/SKILL.md +1 -2
  33. package/src/claude/skills/docx/SKILL.md +1 -1
  34. package/src/claude/skills/frontend-design/SKILL.md +1 -1
  35. package/src/claude/skills/frontend-design/references/ai-multimodal-overview.md +4 -4
  36. package/src/claude/skills/frontend-design/references/analysis-techniques.md +3 -3
  37. package/src/claude/skills/frontend-design/references/asset-generation.md +2 -2
  38. package/src/claude/skills/frontend-design/references/design-extraction-overview.md +4 -4
  39. package/src/claude/skills/frontend-design/references/extraction-best-practices.md +3 -3
  40. package/src/claude/skills/frontend-design/references/technical-accessibility.md +1 -1
  41. package/src/claude/skills/frontend-design/references/technical-optimization.md +1 -1
  42. package/src/claude/skills/frontend-design/references/technical-overview.md +2 -2
  43. package/src/claude/skills/frontend-design/references/technical-workflows.md +6 -6
  44. package/src/claude/skills/frontend-design/references/visual-analysis-overview.md +3 -3
  45. package/src/claude/skills/inspect/SKILL.md +3 -3
  46. package/src/claude/skills/inspect/references/external-gemini-inspection.md +9 -9
  47. package/src/claude/skills/pdf/SKILL.md +1 -1
  48. package/src/claude/skills/pptx/SKILL.md +1 -1
  49. package/src/claude/skills/specs/SKILL.md +1 -1
  50. package/src/claude/skills/xlsx/SKILL.md +1 -1
  51. package/src/opencode/AGENTS.md +1 -2
  52. package/src/opencode/plugins/session.ts +2 -2
  53. package/src/opencode/plugins/spec-state.ts +244 -0
  54. package/src/opencode/plugins/task-scaffold-guard.ts +161 -0
  55. package/src/claude/scripts/browser-tool.cjs +0 -138
  56. package/src/claude/skills/generate-graph/LICENSE +0 -21
  57. package/src/claude/skills/generate-graph/README.md +0 -523
  58. package/src/claude/skills/generate-graph/SKILL.md +0 -429
  59. package/src/claude/skills/generate-graph/agentloop-core.svg +0 -101
  60. package/src/claude/skills/generate-graph/agents/openai.yaml +0 -4
  61. package/src/claude/skills/generate-graph/assets/samples/sample-style1-flat.png +0 -0
  62. package/src/claude/skills/generate-graph/assets/samples/sample-style2-dark.png +0 -0
  63. package/src/claude/skills/generate-graph/assets/samples/sample-style3-blueprint.png +0 -0
  64. package/src/claude/skills/generate-graph/assets/samples/sample-style4-notion.png +0 -0
  65. package/src/claude/skills/generate-graph/assets/samples/sample-style5-glass.png +0 -0
  66. package/src/claude/skills/generate-graph/assets/samples/sample-style6-claude.png +0 -0
  67. package/src/claude/skills/generate-graph/assets/samples/sample-style7-openai.png +0 -0
  68. package/src/claude/skills/generate-graph/fixtures/agent-memory-types-style4.json +0 -181
  69. package/src/claude/skills/generate-graph/fixtures/api-flow-style7.json +0 -40
  70. package/src/claude/skills/generate-graph/fixtures/mem0-style1.json +0 -297
  71. package/src/claude/skills/generate-graph/fixtures/microservices-style3.json +0 -64
  72. package/src/claude/skills/generate-graph/fixtures/multi-agent-style5.json +0 -45
  73. package/src/claude/skills/generate-graph/fixtures/system-architecture-style6.json +0 -48
  74. package/src/claude/skills/generate-graph/fixtures/tool-call-style2.json +0 -182
  75. package/src/claude/skills/generate-graph/package.json +0 -42
  76. package/src/claude/skills/generate-graph/references/icons.md +0 -281
  77. package/src/claude/skills/generate-graph/references/style-1-flat-icon.md +0 -108
  78. package/src/claude/skills/generate-graph/references/style-2-dark-terminal.md +0 -107
  79. package/src/claude/skills/generate-graph/references/style-3-blueprint.md +0 -113
  80. package/src/claude/skills/generate-graph/references/style-4-notion-clean.md +0 -94
  81. package/src/claude/skills/generate-graph/references/style-5-glassmorphism.md +0 -125
  82. package/src/claude/skills/generate-graph/references/style-6-claude-official.md +0 -209
  83. package/src/claude/skills/generate-graph/references/style-7-openai.md +0 -215
  84. package/src/claude/skills/generate-graph/references/style-diagram-matrix.md +0 -135
  85. package/src/claude/skills/generate-graph/references/svg-layout-best-practices.md +0 -100
  86. package/src/claude/skills/generate-graph/scripts/generate-diagram.sh +0 -157
  87. package/src/claude/skills/generate-graph/scripts/generate-from-template.py +0 -1556
  88. package/src/claude/skills/generate-graph/scripts/test-all-styles.sh +0 -135
  89. package/src/claude/skills/generate-graph/scripts/validate-svg.sh +0 -292
  90. package/src/claude/skills/generate-graph/templates/agent-architecture.svg +0 -28
  91. package/src/claude/skills/generate-graph/templates/architecture.svg +0 -23
  92. package/src/claude/skills/generate-graph/templates/comparison-matrix.svg +0 -14
  93. package/src/claude/skills/generate-graph/templates/data-flow.svg +0 -28
  94. package/src/claude/skills/generate-graph/templates/er-diagram.svg +0 -21
  95. package/src/claude/skills/generate-graph/templates/flowchart.svg +0 -21
  96. package/src/claude/skills/generate-graph/templates/sequence.svg +0 -20
  97. package/src/claude/skills/generate-graph/templates/state-machine.svg +0 -20
  98. package/src/claude/skills/generate-graph/templates/timeline.svg +0 -19
  99. package/src/claude/skills/generate-graph/templates/use-case.svg +0 -21
  100. package/src/claude/skills/impact-analysis/SKILL.md +0 -276
  101. package/src/claude/skills/impact-analysis/references/change-detection.md +0 -270
  102. package/src/claude/skills/impact-analysis/references/dependency-scouting.md +0 -338
  103. package/src/claude/skills/impact-analysis/references/edge-case-identification.md +0 -439
  104. package/src/claude/skills/impact-analysis/references/industry-techniques.md +0 -695
  105. package/src/claude/skills/impact-analysis/references/practical-techniques-guide.md +0 -753
  106. package/src/claude/skills/impact-analysis/references/project-detection.md +0 -704
  107. package/src/claude/skills/impact-analysis/references/react-native-customization.md +0 -508
  108. package/src/claude/skills/impact-analysis/references/report-template.md +0 -604
  109. package/src/claude/skills/impact-analysis/references/test-scenario-generation.md +0 -459
  110. package/src/claude/skills/impact-analysis/scripts/README.md +0 -476
  111. package/src/claude/skills/impact-analysis/scripts/ast-analyze.js +0 -403
  112. package/src/claude/skills/impact-analysis/scripts/calculate-risk.js +0 -475
  113. package/src/claude/skills/impact-analysis/scripts/find-dependencies.sh +0 -202
  114. package/src/claude/skills/impact-analysis/scripts/run-analysis.sh +0 -312
package/README.md CHANGED
@@ -131,9 +131,8 @@ CafeKit ships many skills, but the main release surface is:
131
131
  - `/hapo:docs [--init|--update|--summarize|--reconstruct]`: create project docs or reconstruct as-is system documentation from source code
132
132
  - `/hapo:test [scope|--full]`: run verification and return a structured verdict
133
133
  - `/hapo:code-review [scope|--pending]`: adversarial review focused on correctness, regressions, and security
134
- - `/hapo:generate-graph <diagram request>`: generate technical SVG/PNG diagrams
135
134
 
136
- Common companion skills bundled in this package include `inspect`, `impact-analysis`, `research`, `ai-multimodal`, `frontend-development`, `backend-development`, and `react-best-practices`.
135
+ Common companion skills bundled in this package include `inspect`, `research`, `ai-multimodal`, `frontend-development`, `backend-development`, and `react-best-practices`.
137
136
 
138
137
  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:
139
138
 
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 → 'ja'. */
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 : 'ja';
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. */
@@ -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
- existingSettings = JSON.parse(fs.readFileSync(targetPath, 'utf8'));
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: append managed hooks that aren't already present (dedupe by command).
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 managedCommand = managedHook.hooks?.[0]?.command || '';
113
- const isDuplicate = mergedHooks.some((existingHook) =>
114
- existingHook.hooks?.some((h) => h.command === managedCommand));
115
- if (!isDuplicate) {
116
- mergedHooks.push(managedHook);
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.13.4",
3
+ "version": "0.14.1",
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
- python3 packages/spec/src/claude/skills/ui-ux-pro-max/scripts/search.py "<product-type>" --domain product
29
- python3 packages/spec/src/claude/skills/ui-ux-pro-max/scripts/search.py "<style-keywords>" --domain style
30
- python3 packages/spec/src/claude/skills/ui-ux-pro-max/scripts/search.py "<mood>" --domain typography
31
- python3 packages/spec/src/claude/skills/ui-ux-pro-max/scripts/search.py "<industry>" --domain color
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
- if (isSafe(filePath)) continue;
150
- if (!isSensitive(filePath)) continue;
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
- const safe = String(value ?? '').replace(/"/g, '\\"');
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
  }