@haposoft/cafekit 0.18.1 → 0.18.2

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 (93) hide show
  1. package/README.md +12 -1
  2. package/bin/__tests__/claude-settings.test.js +123 -0
  3. package/bin/__tests__/codex-ask-guard.test.js +123 -0
  4. package/bin/__tests__/codex-ask-projection.test.js +51 -0
  5. package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
  6. package/bin/__tests__/codex-hooks.test.js +774 -6
  7. package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
  8. package/bin/__tests__/codex-native.test.js +192 -59
  9. package/bin/__tests__/codex-projection-parity.test.js +314 -0
  10. package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
  11. package/bin/__tests__/codex-research-projection.test.js +70 -0
  12. package/bin/__tests__/codex-specs-projection.test.js +76 -0
  13. package/bin/__tests__/develop-contract.test.js +1 -1
  14. package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
  15. package/bin/__tests__/inspect-paths.test.js +183 -0
  16. package/bin/__tests__/installer-safety.test.js +335 -0
  17. package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
  18. package/bin/__tests__/omp-bridge.test.js +66 -12
  19. package/bin/__tests__/omp-hooks.test.js +11 -5
  20. package/bin/__tests__/orca-session.test.js +7 -0
  21. package/bin/__tests__/privacy-paths.test.js +317 -0
  22. package/bin/__tests__/usage-hook-retired.test.js +5 -3
  23. package/bin/install.js +25 -0
  24. package/bin/lib/codex-install.js +134 -12
  25. package/bin/lib/context.js +1 -1
  26. package/bin/phases/claude-runtime.js +91 -25
  27. package/bin/phases/claude-settings.js +22 -1
  28. package/bin/phases/codex-hooks.js +96 -33
  29. package/bin/phases/codex-runtime.js +45 -52
  30. package/bin/phases/copy-payload.js +1 -3
  31. package/package.json +1 -1
  32. package/src/claude/CLAUDE.md +4 -0
  33. package/src/claude/gitignore +3 -0
  34. package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
  35. package/src/claude/hooks/__tests__/precompact.test.js +206 -10
  36. package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
  37. package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
  38. package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
  39. package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
  40. package/src/claude/hooks/__tests__/state.test.js +207 -7
  41. package/src/claude/hooks/__tests__/update-check.test.js +88 -0
  42. package/src/claude/hooks/agent.cjs +7 -6
  43. package/src/claude/hooks/inspect-block.cjs +15 -63
  44. package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
  45. package/src/claude/hooks/lib/parser.cjs +1 -1
  46. package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
  47. package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
  48. package/src/claude/hooks/lib/project-root.cjs +15 -0
  49. package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
  50. package/src/claude/hooks/lib/spec-touch.cjs +67 -0
  51. package/src/claude/hooks/lib/update-check.cjs +77 -0
  52. package/src/claude/hooks/precompact.cjs +8 -15
  53. package/src/claude/hooks/privacy-block.cjs +23 -205
  54. package/src/claude/hooks/rules.cjs +6 -28
  55. package/src/claude/hooks/session.cjs +34 -117
  56. package/src/claude/hooks/spec-gate.cjs +9 -26
  57. package/src/claude/hooks/spec-state.cjs +43 -59
  58. package/src/claude/hooks/state.cjs +96 -51
  59. package/src/claude/migration-manifest.json +8 -1
  60. package/src/claude/rules/hook-protocols.md +1 -1
  61. package/src/claude/runtime.json +0 -3
  62. package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
  63. package/src/claude/scripts/provenance.cjs +14 -0
  64. package/src/claude/settings/settings.json +1 -1
  65. package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
  66. package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
  67. package/src/claude/skills/git/SKILL.md +1 -1
  68. package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
  69. package/src/codex/AGENTS.md +8 -0
  70. package/src/codex/hooks/ask-guard.cjs +54 -0
  71. package/src/codex/hooks/inspect-block.cjs +11 -7
  72. package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
  73. package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
  74. package/src/codex/hooks/lib/privacy-state.cjs +98 -16
  75. package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
  76. package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
  77. package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
  78. package/src/codex/hooks/lib/spec-touch.cjs +3 -0
  79. package/src/codex/hooks/lib/update-check.cjs +1 -0
  80. package/src/codex/hooks/precompact.cjs +39 -0
  81. package/src/codex/hooks/privacy-approval.cjs +28 -17
  82. package/src/codex/hooks/privacy-block.cjs +9 -215
  83. package/src/codex/hooks/rules.cjs +4 -30
  84. package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
  85. package/src/codex/hooks/session.cjs +47 -11
  86. package/src/codex/hooks/spec-gate.cjs +21 -6
  87. package/src/codex/hooks/spec-state.cjs +32 -6
  88. package/src/codex/hooks/state.cjs +25 -4
  89. package/src/codex/hooks.json +39 -7
  90. package/src/codex/rules/hook-protocols.md +13 -6
  91. package/src/omp/extensions/cafekit-bridge.mjs +12 -4
  92. package/src/omp/hooks/privacy-block.cjs +24 -206
  93. package/src/codex/rules/state-sync.md +0 -60
@@ -0,0 +1,313 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { commandAccess } = require('./privacy-command-analysis.cjs');
6
+
7
+ const RESTRICTED = [
8
+ /^\.env(?:[.\[*?{]|$)/i,
9
+ /^credentials/i,
10
+ /secrets?\.(ya?ml|json)$/i,
11
+ /\.pem$/i,
12
+ /\.key$/i,
13
+ /\.p12$/i,
14
+ /\.pfx$/i,
15
+ /^id_(rsa|ed25519|ecdsa|dsa)$/i,
16
+ /\.netrc$/i,
17
+ /\.pgpass$/i,
18
+ /kubeconfig/i,
19
+ /\.keystore$/i,
20
+ /\.jks$/i,
21
+ /auth\.json$/i,
22
+ /token(s)?\.json$/i
23
+ ];
24
+
25
+ const EXEMPT = [/\.env\.(example|sample|template|test)$/i];
26
+
27
+ function matchesAny(filePath, rules) {
28
+ const base = path.basename(filePath);
29
+ return rules.some((rule) => rule.test(base) || rule.test(filePath));
30
+ }
31
+
32
+ function isSafe(filePath) {
33
+ return matchesAny(filePath, EXEMPT);
34
+ }
35
+
36
+ function isSensitive(filePath) {
37
+ return matchesAny(filePath, RESTRICTED);
38
+ }
39
+
40
+ function resolveTarget(filePath, cwd) {
41
+ try {
42
+ const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
43
+ return fs.realpathSync(requested);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * When realpath fails, check whether the requested path is a symlink (or chain)
51
+ * whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed deny);
52
+ * dangling benign link or link to allowed example/template -> false (allow).
53
+ * Limit hops to avoid loops; malformed/looping -> fail closed.
54
+ * TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
55
+ * This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
56
+ */
57
+ function isSymlinkToSensitive(filePath, cwd) {
58
+ try {
59
+ const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
60
+ let current = requested;
61
+ let hops = 0;
62
+ const MAX_HOPS = 8;
63
+ while (hops < MAX_HOPS) {
64
+ let stat;
65
+ try {
66
+ stat = fs.lstatSync(current);
67
+ } catch {
68
+ return false;
69
+ }
70
+ if (!stat.isSymbolicLink()) return false;
71
+ let linkTarget;
72
+ try {
73
+ linkTarget = fs.readlinkSync(current);
74
+ } catch {
75
+ return true;
76
+ }
77
+ if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
78
+ if (isSafe(linkTarget)) return false;
79
+ const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
80
+ if (next === current) return true;
81
+ try {
82
+ const real = fs.realpathSync(next);
83
+ if (isSensitive(real) && !isSafe(real)) return true;
84
+ if (isSafe(real)) return false;
85
+ return false;
86
+ } catch {
87
+ current = next;
88
+ hops += 1;
89
+ continue;
90
+ }
91
+ }
92
+ return true;
93
+ } catch {
94
+ return true;
95
+ }
96
+ }
97
+
98
+ function scrubCommand(command) {
99
+ return String(command).replace(
100
+ /(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2([^\n]*)\n[\s\S]*?\n[ \t]*\3\b/g,
101
+ '$1$2$3$2$4'
102
+ );
103
+ }
104
+
105
+ function extractCommandPaths(command) {
106
+ // The analyzer parses the command and returns the operands of every reading
107
+ // command it recognises, nested shells and `find -exec` included. Treating
108
+ // every unresolved expansion as sensitive, as this did before, denied
109
+ // `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike -- and every denial here
110
+ // costs a round trip through a one-shot approval prompt.
111
+ // A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell expands
112
+ // nothing inside it, so no word in the body is ever read. The analyzer has no
113
+ // notion of heredocs and parses the body as shell, so prose that quotes a
114
+ // command -- a PR body carrying `cat .env` in backticks -- came back as a real
115
+ // command substitution. Blank those bodies out first. An unquoted delimiter
116
+ // (<<BODY) does expand, so it is left to the analyzer.
117
+ const scrubbed = scrubCommand(command);
118
+
119
+ const paths = new Set(commandAccess(scrubbed).paths);
120
+
121
+ // Two shapes the analyzer cannot return. It drops any word carrying an
122
+ // expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched by
123
+ // name. And it only walks commands it recognises, so an unknown tool handed a
124
+ // secret through a file-shaped option, such as
125
+ // `docker compose --env-file .env`, is invisible to it.
126
+ const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
127
+ const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
128
+ for (let index = 0; index < tokens.length; index += 1) {
129
+ const token = tokens[index];
130
+ const shaped = /^~|[*?[{]/.test(token);
131
+ const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
132
+ if (!shaped && !optionFed) continue;
133
+ const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
134
+ if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
135
+ }
136
+
137
+ return [...paths];
138
+ }
139
+
140
+ function extractPatchPaths(patch) {
141
+ return [...String(patch).matchAll(
142
+ /^\*\*\* (?:(?:Add|Update|Delete) File:|Move to:)\s+(.+)$/gm
143
+ )]
144
+ .map((match) => match[1].trim())
145
+ .filter(Boolean);
146
+ }
147
+
148
+
149
+ function shellInput(input) {
150
+ const value = input?.command ?? input?.cmd;
151
+ if (typeof value === 'string') return value;
152
+ if (!Array.isArray(value)) return '';
153
+ const index = value.findIndex(word => word === '-c' || word === '-lc');
154
+ return index >= 0 ? String(value[index + 1] || '') : value.join(' ');
155
+ }
156
+
157
+ function patchInput(input) {
158
+ if (typeof input === 'string') return input;
159
+ for (const key of ['command', 'patch', 'input']) {
160
+ if (typeof input?.[key] === 'string') return input[key];
161
+ }
162
+ return '';
163
+ }
164
+
165
+ function isPathKey(key, mcp) {
166
+ return /^(?:file_?paths?|paths?|search_?paths?|director(?:y|ies)|cwd|root|locations?|notebook_path|globs?)$/i.test(key)
167
+ || (mcp && /path|file|dir|uri|source|dest|target/i.test(key));
168
+ }
169
+
170
+ function collectPathValues(value, key, out, mcp) {
171
+ if (typeof value === 'string' && isPathKey(key, mcp)) {
172
+ const filePath = value.trim().replace(/^file:\/\//i, '');
173
+ if (filePath) out.push(filePath);
174
+ } else if (Array.isArray(value)) {
175
+ for (const item of value) collectPathValues(item, key, out, mcp);
176
+ } else if (value && typeof value === 'object') {
177
+ for (const [childKey, childValue] of Object.entries(value)) {
178
+ collectPathValues(childValue, childKey, out, mcp);
179
+ }
180
+ }
181
+ }
182
+
183
+ function extractPaths(toolName, input) {
184
+ const paths = [];
185
+ const tool = String(toolName).toLowerCase();
186
+ collectPathValues(input, '', paths, tool.startsWith('mcp__'));
187
+ if (['bash', 'exec_command', 'shell'].includes(tool)) {
188
+ paths.push(...extractCommandPaths(shellInput(input)));
189
+ } else if (['apply_patch', 'edit', 'write'].includes(tool)) {
190
+ paths.push(...extractPatchPaths(patchInput(input)));
191
+ }
192
+ return [...new Set(paths)];
193
+ }
194
+
195
+ function sensitiveRequest(filePath, cwd) {
196
+ const target = resolveTarget(filePath, cwd);
197
+ if (target) {
198
+ if (isSensitive(target) && !isSafe(target)) return true;
199
+ if (isSafe(target)) return false;
200
+ } else {
201
+ if (isSymlinkToSensitive(filePath, cwd)) return true;
202
+ }
203
+ return !isSafe(filePath) && isSensitive(filePath);
204
+ }
205
+
206
+ function protectedLocation(filePath) {
207
+ const normalized = path.posix.normalize(filePath).toLowerCase();
208
+ const parts = normalized.split('/');
209
+ const runtimes = new Set(['.claude', '.codex', '.omp']);
210
+ if (runtimes.has(parts.at(-1)) || parts.at(-1) === '.privacy') return true;
211
+ if (parts.at(-1) === 'runtime.json' && runtimes.has(parts.at(-2))) return true;
212
+ for (let index = 0; index + 2 < parts.length; index += 1) {
213
+ if (parts[index] === '.codex' && parts[index + 1] === 'hooks' && parts[index + 2] === '.privacy') return true;
214
+ }
215
+ return false;
216
+ }
217
+
218
+ function protectedWrite(filePath, cwd) {
219
+ const absolute = path.posix.resolve(cwd.replace(/\\/g, '/'), filePath.replace(/\\/g, '/'));
220
+ if (protectedLocation(absolute)) return true;
221
+ let current = absolute;
222
+ const suffix = [];
223
+ const links = new Set();
224
+ for (;;) {
225
+ try {
226
+ if (fs.lstatSync(current).isSymbolicLink()) {
227
+ if (links.has(current) || links.size >= 8) return true;
228
+ links.add(current);
229
+ const target = path.posix.resolve(path.posix.dirname(current), fs.readlinkSync(current));
230
+ if (protectedLocation(path.posix.join(target, ...suffix.slice().reverse()))) return true;
231
+ current = target;
232
+ continue;
233
+ }
234
+ const real = fs.realpathSync(current);
235
+ return protectedLocation(path.posix.join(real, ...suffix.reverse()));
236
+ } catch (error) {
237
+ if (error.code === 'ELOOP') return true;
238
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') return false;
239
+ }
240
+ const parent = path.posix.dirname(current);
241
+ if (parent === current) return false;
242
+ suffix.push(path.posix.basename(current));
243
+ current = parent;
244
+ }
245
+ }
246
+
247
+ function extractWritePaths(tool, input) {
248
+ if (['bash', 'exec_command', 'shell'].includes(tool)) return commandAccess(scrubCommand(shellInput(input)));
249
+ const paths = [];
250
+ if (['write', 'edit', 'multiedit', 'notebookedit'].includes(tool) || tool.startsWith('mcp__')) {
251
+ collectPathValues(input, '', paths, tool.startsWith('mcp__'));
252
+ }
253
+ if (['apply_patch', 'edit', 'write'].includes(tool)) paths.push(...extractPatchPaths(patchInput(input)));
254
+ return { writes: paths, unknownWrites: [] };
255
+ }
256
+
257
+ function protectedUnknownWrite(filePath) {
258
+ const parts = path.posix.normalize(filePath.replace(/\\/g, '/')).toLowerCase().split('/');
259
+ return parts.at(-1) === 'runtime.json' || parts.includes('.privacy')
260
+ || ['.claude', '.codex', '.omp'].includes(parts.at(-1));
261
+ }
262
+
263
+ function sensitivePaths(toolName, input, cwd) {
264
+ const paths = extractPaths(toolName, input).filter(filePath => sensitiveRequest(filePath, cwd));
265
+ const tool = String(toolName).toLowerCase();
266
+ const access = extractWritePaths(tool, input);
267
+ paths.push(...access.writes.filter(filePath => protectedWrite(filePath, cwd)),
268
+ ...access.unknownWrites.filter(protectedUnknownWrite));
269
+ const searches = tool === 'grep'
270
+ ? [{ recursive: true, hidden: true,
271
+ roots: [typeof input?.path === 'string' && input.path.trim() ? input.path : cwd],
272
+ includes: [input?.glob, ...(Array.isArray(input?.globs) ? input.globs : [])]
273
+ .filter(glob => typeof glob === 'string' && glob),
274
+ }]
275
+ : ['bash', 'exec_command', 'shell'].includes(tool)
276
+ ? commandAccess(scrubCommand(shellInput(input))).searches : [];
277
+ for (const search of searches) {
278
+ if (!search.recursive) continue;
279
+ // Negative globs and exclusion/type flags cannot prove a sensitive child is omitted.
280
+ const includes = search.includes.filter(glob => glob && !glob.startsWith('!'));
281
+ for (const root of search.roots) {
282
+ const directory = path.resolve(cwd, root);
283
+ let entries;
284
+ try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { continue; }
285
+ for (const entry of entries) {
286
+ if (entry.isDirectory() || (!search.hidden && entry.name.startsWith('.'))) continue;
287
+ if (includes.length && !includes.some(glob => matchesInclude(entry.name, glob))) continue;
288
+ const filePath = path.join(directory, entry.name);
289
+ if (sensitiveRequest(filePath, cwd)) paths.push(filePath);
290
+ }
291
+ }
292
+ }
293
+ return [...new Set(paths)];
294
+ }
295
+
296
+ function matchesInclude(name, glob) {
297
+ if (typeof path.matchesGlob !== 'function') return true;
298
+ glob = glob.replace(/^\/+/, '');
299
+ if (glob.length > 256 || (glob.match(/[*?]/g) || []).length > 4 || /\{[^}]*\.\.[^}]*\}/.test(glob)
300
+ || !/^[\w.\-\/]*(?:[*?][\w.\-\/]*){0,4}(?:\{[\w.\-*,]*\}[\w.\-\/]*)?$/.test(glob)) return true;
301
+ // Prefixing both sides removes Node's implicit leading-dot exclusion for a basename.
302
+ return path.matchesGlob(name, glob)
303
+ || (name.startsWith('.') && path.matchesGlob(`x${name}`, `x${glob.replace(/^(?:\*\*\/)+/, '')}`));
304
+ }
305
+
306
+ function displayNames(filePaths) {
307
+ const names = [...new Set(filePaths.map((filePath) => path.basename(filePath)))].sort();
308
+ return names.length <= 3
309
+ ? names.join(', ')
310
+ : `${names.slice(0, 3).join(', ')} (+${names.length - 3} more)`;
311
+ }
312
+
313
+ module.exports = { isSensitive, isSafe, sensitiveRequest, extractPaths, sensitivePaths, displayNames };
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const runtimeRoot = path.resolve(__dirname, '../..');
6
+
7
+ function projectRoot(payload = {}) {
8
+ const installed = path.basename(runtimeRoot).startsWith('.') ? path.dirname(runtimeRoot) : '';
9
+ const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string' ? process.env.CLAUDE_PROJECT_DIR.trim() : '';
10
+ const cwd = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
11
+ const root = path.resolve(installed || configured || cwd || process.cwd());
12
+ try { return fs.realpathSync(root); } catch { return root; }
13
+ }
14
+
15
+ module.exports = { projectRoot };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { hookStateDir } = require('./hook-state-dir.cjs');
7
+
8
+ function reservationFile(sessionId) {
9
+ if (!sessionId) return null;
10
+ const key = crypto.createHash('sha256').update(String(sessionId)).digest('hex');
11
+ return path.join(hookStateDir(), `rules-${key}.json`);
12
+ }
13
+
14
+ /** Reserve one injection slot per session. Returns null on reservation errors. */
15
+ function reserveSession(sessionId) {
16
+ try {
17
+ const file = reservationFile(sessionId);
18
+ if (!file) return null;
19
+ fs.mkdirSync(path.dirname(file), { recursive: true });
20
+ fs.writeFileSync(file, JSON.stringify({ sessionId: String(sessionId) }), {
21
+ encoding: 'utf8', flag: 'wx', mode: 0o600,
22
+ });
23
+ return true;
24
+ } catch (error) {
25
+ return error.code === 'EEXIST' ? false : null;
26
+ }
27
+ }
28
+
29
+ function clearSessionReservation(sessionId) {
30
+ try {
31
+ const file = reservationFile(sessionId);
32
+ if (file) fs.rmSync(file, { force: true });
33
+ } catch { /* fail-open */ }
34
+ }
35
+
36
+ module.exports = { reserveSession, clearSessionReservation };
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // Deleted/new paths still resolve through their deepest existing ancestor. This also
7
+ // prevents an innocent lexical prefix from hiding a symlink outside the specs root.
8
+ function canonicalTarget(value) {
9
+ const missing = [];
10
+ let current = path.resolve(value);
11
+ while (true) {
12
+ try { return path.join(fs.realpathSync(current), ...missing); } catch (error) {
13
+ if (error.code !== 'ENOENT') return null;
14
+ const parent = path.dirname(current);
15
+ if (parent === current) return null;
16
+ missing.unshift(path.basename(current));
17
+ current = parent;
18
+ }
19
+ }
20
+ }
21
+
22
+ function packetAt(value, specsRoot, cwd) {
23
+ if (typeof value !== 'string' || !value.trim()) return null;
24
+ const root = canonicalTarget(specsRoot);
25
+ const target = canonicalTarget(path.resolve(cwd, value));
26
+ if (!root || !target) return null;
27
+ const rel = path.relative(root, target);
28
+ if (!rel || path.isAbsolute(rel) || rel === '..' || rel.startsWith(`..${path.sep}`)) return null;
29
+ const segments = rel.split(path.sep);
30
+ return segments[0] === '_shared' ? null : segments[0];
31
+ }
32
+
33
+ function namedPackets(prompt, names, specsRoot, projectRoot) {
34
+ if (typeof prompt !== 'string') return [];
35
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
36
+ const prefix = path.relative(projectRoot, specsRoot).split(path.sep).join('/');
37
+ const named = names.filter((name) => {
38
+ const n = escape(name);
39
+ const absolute = specsRoot.split(path.sep).join('/');
40
+ return new RegExp(`(^|[^\\w])${escape(absolute)}/${n}(?![\\w-])`).test(prompt)
41
+ || new RegExp(`(^|[^\\w/-])${escape(prefix)}/${n}(?![\\w-])`).test(prompt)
42
+ || new RegExp(`(?:\\$cf-[\\w-]+|/cf:[\\w-]+)\\s+(?:${escape(prefix)}/)?${n}(?![\\w-])`).test(prompt)
43
+ || (/[-_0-9]/.test(name) && new RegExp(`(^|[^\\w/-])${n}(?![\\w-])`).test(prompt));
44
+ });
45
+ // Absolute prompt references may use another realpath alias (notably /var on macOS).
46
+ const paths = [...prompt.matchAll(/(?:^|[\s"'`])((?:\/|[A-Za-z]:[\\/])[^\s"'`]+)/g)].map((match) => match[1]);
47
+ for (const match of prompt.matchAll(/["']([^"']+)["']/g)) {
48
+ if (path.isAbsolute(match[1])) paths.push(match[1]);
49
+ }
50
+ for (const value of paths) {
51
+ const name = packetAt(value, specsRoot, projectRoot);
52
+ if (names.includes(name)) named.push(name);
53
+ }
54
+ return [...new Set(named)];
55
+ }
56
+
57
+ function explicitFailure(payload) {
58
+ const failed = (value) => {
59
+ if (!value || typeof value !== 'object') return false;
60
+ if (value.success === false || value.is_error === true || value.isError === true
61
+ || value.ok === false || value.error || ['error', 'failed', 'failure'].includes(value.status)) return true;
62
+ return ['exit_code', 'exitCode'].some((key) => value[key] !== undefined && value[key] !== 0);
63
+ };
64
+ return payload.success === false || failed(payload.tool_response);
65
+ }
66
+
67
+ module.exports = { canonicalTarget, packetAt, namedPackets, explicitFailure };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const VERSION = /^\d+\.\d+\.\d+$/;
6
+ const CACHE_TTL = 12 * 60 * 60 * 1000;
7
+
8
+ function fetchLatestVersion() {
9
+ try {
10
+ const https = require('https');
11
+ return new Promise((resolve) => {
12
+ const req = https.get(
13
+ 'https://registry.npmjs.org/@haposoft/cafekit/latest',
14
+ { timeout: 3000, headers: { Accept: 'application/json' } },
15
+ (res) => {
16
+ let body = '';
17
+ res.on('error', () => resolve(null));
18
+ res.on('aborted', () => resolve(null));
19
+ res.on('data', (chunk) => { body += chunk; });
20
+ res.on('end', () => {
21
+ try { resolve(JSON.parse(body).version || null); } catch { resolve(null); }
22
+ });
23
+ }
24
+ );
25
+ req.on('error', () => resolve(null));
26
+ req.on('timeout', () => { req.destroy(); resolve(null); });
27
+ });
28
+ } catch { return Promise.resolve(null); }
29
+ }
30
+
31
+ function isNewer(a, b) {
32
+ const n = (v) => String(v).split('.').map((x) => parseInt(x, 10) || 0);
33
+ const [aN, bN] = [n(a), n(b)];
34
+ for (let i = 0; i < 3; i++) {
35
+ if (bN[i] > aN[i]) return true;
36
+ if (bN[i] < aN[i]) return false;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ function getInstalledVersion(runtimeDirectory) {
42
+ try {
43
+ const meta = JSON.parse(fs.readFileSync(path.join(runtimeDirectory, 'cafekit.json'), 'utf8'));
44
+ return typeof meta.version === 'string' ? meta.version : null;
45
+ } catch { return null; }
46
+ }
47
+
48
+ function noticeFor(installed, latest) {
49
+ return isNewer(installed, latest)
50
+ ? `\n⚡ CafeKit update available: ${installed} → ${latest}\n Run: npx @haposoft/cafekit\n`
51
+ : '';
52
+ }
53
+
54
+ async function checkCafeKitUpdate(runtimeDirectory, fetchVersion = fetchLatestVersion) {
55
+ const installed = getInstalledVersion(runtimeDirectory);
56
+ if (!installed) return '';
57
+ const cachePath = path.join(runtimeDirectory, '.cafekit-update-cache.json');
58
+ const now = Date.now();
59
+ try {
60
+ const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
61
+ if (typeof cache.ts === 'number' && Number.isFinite(cache.ts) && cache.ts <= now
62
+ && now - cache.ts < CACHE_TTL && typeof cache.latest === 'string' && VERSION.test(cache.latest)) {
63
+ return noticeFor(installed, cache.latest);
64
+ }
65
+ } catch {
66
+ // Missing, corrupt or legacy cache requires a fresh version fetch.
67
+ }
68
+ let latest;
69
+ try { latest = await fetchVersion(); } catch { return ''; }
70
+ if (typeof latest !== 'string' || !VERSION.test(latest)) return '';
71
+ try {
72
+ fs.writeFileSync(cachePath, JSON.stringify({ ts: Date.now(), latest }), 'utf8');
73
+ } catch { /* cache persistence is optional */ }
74
+ return noticeFor(installed, latest);
75
+ }
76
+
77
+ module.exports = { fetchLatestVersion, isNewer, getInstalledVersion, checkCafeKitUpdate };
@@ -23,27 +23,16 @@
23
23
 
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
+ const crypto = require('crypto');
26
27
  const { spawnSync } = require('child_process');
27
28
 
28
- const RECOVERY_FILE = 'compact-recovery.json';
29
-
30
29
  function gitLine(root, args) {
31
- const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8' });
30
+ const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', timeout: 3000 });
32
31
  if (result.error || result.status !== 0) return null;
33
32
  const value = String(result.stdout || '').trim();
34
33
  return value === '' ? null : value;
35
34
  }
36
35
 
37
- function projectRoot(payload) {
38
- const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string' ? process.env.CLAUDE_PROJECT_DIR.trim() : '';
39
- const candidates = [configured, typeof payload.cwd === 'string' ? payload.cwd.trim() : '', process.cwd()];
40
- for (const candidate of candidates) {
41
- if (!candidate) continue;
42
- try { return fs.realpathSync(path.resolve(candidate)); } catch { /* next */ }
43
- }
44
- return process.cwd();
45
- }
46
-
47
36
  /** The one task the packet currently says is in flight, if any. */
48
37
  function activeTask(root) {
49
38
  try {
@@ -72,13 +61,16 @@ try {
72
61
  const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
73
62
  const payload = stdin ? normalizeHookPayload(JSON.parse(stdin)) : {};
74
63
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) process.exit(0);
64
+ const sessionId = payload.session_id;
65
+ if (typeof sessionId !== 'string' || !sessionId.trim()) process.exit(0);
75
66
 
67
+ const { projectRoot } = require('./lib/project-root.cjs');
76
68
  const root = projectRoot(payload);
77
69
  const status = gitLine(root, ['status', '--porcelain']);
78
70
  const record = {
79
71
  captured_at: new Date().toISOString(),
80
72
  trigger: typeof payload.trigger === 'string' ? payload.trigger : null,
81
- session_id: typeof payload.session_id === 'string' ? payload.session_id : null,
73
+ session_id: sessionId,
82
74
  project_root: root,
83
75
  branch: gitLine(root, ['rev-parse', '--abbrev-ref', 'HEAD']),
84
76
  head: gitLine(root, ['rev-parse', '--short', 'HEAD']),
@@ -88,7 +80,8 @@ try {
88
80
 
89
81
  const dir = require('./lib/hook-state-dir.cjs').hookStateDir();
90
82
  fs.mkdirSync(dir, { recursive: true });
91
- const target = path.join(dir, RECOVERY_FILE);
83
+ const key = crypto.createHash('sha256').update(sessionId).digest('hex');
84
+ const target = path.join(dir, `compact-recovery-${key}.json`);
92
85
  const temp = `${target}.${process.pid}.tmp`;
93
86
  fs.writeFileSync(temp, `${JSON.stringify(record, null, 2)}\n`);
94
87
  fs.renameSync(temp, target);