@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.
- package/README.md +12 -1
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +774 -6
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +192 -59
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +18 -8
- package/bin/__tests__/omp-bridge.test.js +66 -12
- package/bin/__tests__/omp-hooks.test.js +11 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/privacy-paths.test.js +317 -0
- package/bin/__tests__/usage-hook-retired.test.js +5 -3
- package/bin/install.js +25 -0
- package/bin/lib/codex-install.js +134 -12
- package/bin/lib/context.js +1 -1
- package/bin/phases/claude-runtime.js +91 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +96 -33
- package/bin/phases/codex-runtime.js +45 -52
- package/bin/phases/copy-payload.js +1 -3
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +4 -0
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/precompact.test.js +206 -10
- package/src/claude/hooks/__tests__/privacy-block.test.js +27 -0
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +141 -10
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +7 -6
- package/src/claude/hooks/inspect-block.cjs +15 -63
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +191 -17
- package/src/claude/hooks/lib/privacy-paths.cjs +313 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +67 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +23 -205
- package/src/claude/hooks/rules.cjs +6 -28
- package/src/claude/hooks/session.cjs +34 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +43 -59
- package/src/claude/hooks/state.cjs +96 -51
- package/src/claude/migration-manifest.json +8 -1
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +2 -2
- package/src/claude/scripts/provenance.cjs +14 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/git/SKILL.md +1 -1
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/codex/AGENTS.md +8 -0
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +11 -7
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +98 -16
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +39 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +9 -215
- package/src/codex/hooks/rules.cjs +4 -30
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +47 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/omp/extensions/cafekit-bridge.mjs +12 -4
- package/src/omp/hooks/privacy-block.cjs +24 -206
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -14,32 +14,22 @@
|
|
|
14
14
|
* Exit: 0 (the JSON hook output carries the permission decision)
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
const fs = require('fs');
|
|
19
|
-
const path = require('path');
|
|
20
|
-
const { commandAccess } = require('./lib/privacy-command-analysis.cjs');
|
|
17
|
+
const EVALUATION_FAILURE = 'Sensitive access could not be evaluated safely.';
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
/\.netrc$/i,
|
|
32
|
-
/\.pgpass$/i,
|
|
33
|
-
/kubeconfig/i,
|
|
34
|
-
/\.keystore$/i,
|
|
35
|
-
/\.jks$/i,
|
|
36
|
-
/auth\.json$/i,
|
|
37
|
-
/token(s)?\.json$/i
|
|
38
|
-
];
|
|
19
|
+
function askForPermission(reason) {
|
|
20
|
+
process.stdout.write(JSON.stringify({
|
|
21
|
+
hookSpecificOutput: {
|
|
22
|
+
hookEventName: 'PreToolUse',
|
|
23
|
+
permissionDecision: 'ask',
|
|
24
|
+
permissionDecisionReason: reason
|
|
25
|
+
}
|
|
26
|
+
}) + '\n');
|
|
27
|
+
}
|
|
39
28
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
29
|
+
try {
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const { sensitivePaths, displayNames } = require('./lib/privacy-paths.cjs');
|
|
32
|
+
const { projectRoot: resolveProjectRoot } = require('./lib/project-root.cjs');
|
|
43
33
|
|
|
44
34
|
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
45
35
|
function readRuntime(cwd) {
|
|
@@ -51,199 +41,27 @@ try {
|
|
|
51
41
|
}
|
|
52
42
|
}
|
|
53
43
|
|
|
54
|
-
function isSafe(filePath) {
|
|
55
|
-
const base = path.basename(filePath);
|
|
56
|
-
return ALLOWED_EXEMPTIONS.some((rule) => rule.test(base) || rule.test(filePath));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function isSensitive(filePath) {
|
|
60
|
-
const base = path.basename(filePath);
|
|
61
|
-
return RESTRICTED_PATTERNS.some((rule) => rule.test(base) || rule.test(filePath));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Resolve a symlink to its real target so a harmless-looking name that points
|
|
66
|
-
* at a sensitive file cannot slip through the basename check. Returns null
|
|
67
|
-
* when the path cannot be resolved (missing file, broken link) ā fail-open to
|
|
68
|
-
* the original-path check in that case. See isSymlinkToSensitive for dangling handling.
|
|
69
|
-
*/
|
|
70
|
-
function resolveTarget(filePath, cwd) {
|
|
71
|
-
try {
|
|
72
|
-
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
73
|
-
return fs.realpathSync(requested);
|
|
74
|
-
} catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* When realpath fails, check whether the requested path is a symlink (or chain)
|
|
81
|
-
* whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed ask);
|
|
82
|
-
* dangling benign link or link to allowed example/template -> false (allow).
|
|
83
|
-
* Limit hops to avoid loops; malformed/looping -> fail closed.
|
|
84
|
-
* TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
|
|
85
|
-
* This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
|
|
86
|
-
*/
|
|
87
|
-
function isSymlinkToSensitive(filePath, cwd) {
|
|
88
|
-
try {
|
|
89
|
-
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
90
|
-
let current = requested;
|
|
91
|
-
let hops = 0;
|
|
92
|
-
const MAX_HOPS = 8;
|
|
93
|
-
while (hops < MAX_HOPS) {
|
|
94
|
-
let stat;
|
|
95
|
-
try {
|
|
96
|
-
stat = fs.lstatSync(current);
|
|
97
|
-
} catch {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
if (!stat.isSymbolicLink()) return false;
|
|
101
|
-
let linkTarget;
|
|
102
|
-
try {
|
|
103
|
-
linkTarget = fs.readlinkSync(current);
|
|
104
|
-
} catch {
|
|
105
|
-
return true;
|
|
106
|
-
}
|
|
107
|
-
if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
|
|
108
|
-
if (isSafe(linkTarget)) return false;
|
|
109
|
-
const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
|
|
110
|
-
if (next === current) return true;
|
|
111
|
-
try {
|
|
112
|
-
const real = fs.realpathSync(next);
|
|
113
|
-
if (isSensitive(real) && !isSafe(real)) return true;
|
|
114
|
-
if (isSafe(real)) return false;
|
|
115
|
-
return false;
|
|
116
|
-
} catch {
|
|
117
|
-
current = next;
|
|
118
|
-
hops += 1;
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return true;
|
|
123
|
-
} catch {
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function extractBashPaths(command) {
|
|
129
|
-
// The analyzer parses the command and returns the operands of every reading
|
|
130
|
-
// command it recognises, nested shells and `find -exec` included. Treating
|
|
131
|
-
// every unresolved expansion as sensitive, as this did before, asked on
|
|
132
|
-
// `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike. Noise is what trains
|
|
133
|
-
// people to click through the prompt that matters.
|
|
134
|
-
// A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell
|
|
135
|
-
// expands nothing inside it, so no word in the body is ever read. The
|
|
136
|
-
// analyzer has no notion of heredocs and parses the body as shell, so prose
|
|
137
|
-
// that quotes a command -- a PR body carrying `cat .env` in backticks --
|
|
138
|
-
// came back as a real command substitution. Blank those bodies out first.
|
|
139
|
-
// An unquoted delimiter (<<BODY) does expand, so it is left to the
|
|
140
|
-
// analyzer.
|
|
141
|
-
const scrubbed = String(command).replace(
|
|
142
|
-
/(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2[^\n]*\n[\s\S]*?\n[ \t]*\3\b/g,
|
|
143
|
-
'$1$2$3$2'
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
const paths = new Set(commandAccess(scrubbed).paths);
|
|
147
|
-
|
|
148
|
-
// Two shapes the analyzer cannot return. It drops any word carrying an
|
|
149
|
-
// expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched
|
|
150
|
-
// by name. And it only walks commands it recognises, so an unknown tool
|
|
151
|
-
// handed a secret through a file-shaped option, such as
|
|
152
|
-
// `docker compose --env-file .env`, is invisible to it.
|
|
153
|
-
const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
|
|
154
|
-
const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
|
|
155
|
-
for (let index = 0; index < tokens.length; index += 1) {
|
|
156
|
-
const token = tokens[index];
|
|
157
|
-
const shaped = /^~|[*?[{]/.test(token);
|
|
158
|
-
const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
|
|
159
|
-
if (!shaped && !optionFed) continue;
|
|
160
|
-
const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
|
|
161
|
-
if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return [...paths];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function extractPaths(toolName, input) {
|
|
168
|
-
const paths = [];
|
|
169
|
-
if (!input) return paths;
|
|
170
|
-
|
|
171
|
-
for (const key of ['file_path', 'path']) {
|
|
172
|
-
if (typeof input[key] === 'string' && input[key].trim()) {
|
|
173
|
-
paths.push(input[key].trim());
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
for (const key of ['paths', 'search_paths']) {
|
|
178
|
-
if (Array.isArray(input[key])) {
|
|
179
|
-
paths.push(...input[key].filter(Boolean));
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (toolName === 'Bash' && typeof input.command === 'string') {
|
|
184
|
-
paths.push(...extractBashPaths(input.command));
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return paths.filter(Boolean);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function askForPermission(filePath) {
|
|
191
|
-
const basename = path.basename(filePath);
|
|
192
|
-
process.stdout.write(JSON.stringify({
|
|
193
|
-
hookSpecificOutput: {
|
|
194
|
-
hookEventName: 'PreToolUse',
|
|
195
|
-
permissionDecision: 'ask',
|
|
196
|
-
permissionDecisionReason: `Sensitive file access requires approval: ${basename}`
|
|
197
|
-
}
|
|
198
|
-
}) + '\n');
|
|
199
|
-
}
|
|
200
|
-
|
|
201
44
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
202
|
-
if (!stdin)
|
|
45
|
+
if (!stdin) {
|
|
46
|
+
askForPermission(EVALUATION_FAILURE);
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
203
49
|
|
|
204
50
|
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
205
51
|
const data = normalizeHookPayload(JSON.parse(stdin));
|
|
206
52
|
const toolName = data.tool_name || '';
|
|
207
53
|
const toolInput = data.tool_input || {};
|
|
208
54
|
const cwd = typeof data.cwd === 'string' && data.cwd.trim() ? data.cwd : process.cwd();
|
|
209
|
-
const
|
|
55
|
+
const projectRoot = resolveProjectRoot(data);
|
|
56
|
+
const runtime = readRuntime(projectRoot);
|
|
210
57
|
|
|
211
58
|
if (runtime.privacyBlock === false) process.exit(0);
|
|
212
59
|
|
|
213
|
-
const paths =
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
for (const filePath of paths) {
|
|
217
|
-
const target = resolveTarget(filePath, cwd);
|
|
218
|
-
|
|
219
|
-
// The real target is authoritative: an exempt-looking alias must not hide
|
|
220
|
-
// a sensitive target, while a real .env.example target remains safe.
|
|
221
|
-
if (target) {
|
|
222
|
-
if (isSensitive(target) && !isSafe(target)) {
|
|
223
|
-
askForPermission(filePath);
|
|
224
|
-
process.exit(0);
|
|
225
|
-
}
|
|
226
|
-
if (isSafe(target)) continue;
|
|
227
|
-
} else {
|
|
228
|
-
if (isSymlinkToSensitive(filePath, cwd)) {
|
|
229
|
-
askForPermission(filePath);
|
|
230
|
-
process.exit(0);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (isSafe(filePath) || !isSensitive(filePath)) continue;
|
|
235
|
-
askForPermission(filePath);
|
|
236
|
-
process.exit(0);
|
|
237
|
-
}
|
|
60
|
+
const paths = sensitivePaths(toolName, toolInput, cwd);
|
|
61
|
+
if (paths.length) askForPermission(`Sensitive file access requires approval: ${displayNames(paths)}`);
|
|
238
62
|
|
|
239
63
|
process.exit(0);
|
|
240
64
|
} catch (_) {
|
|
241
|
-
|
|
242
|
-
hookSpecificOutput: {
|
|
243
|
-
hookEventName: 'PreToolUse',
|
|
244
|
-
permissionDecision: 'ask',
|
|
245
|
-
permissionDecisionReason: 'Sensitive access could not be evaluated safely.'
|
|
246
|
-
}
|
|
247
|
-
}) + '\n');
|
|
65
|
+
askForPermission(EVALUATION_FAILURE);
|
|
248
66
|
process.exit(0);
|
|
249
67
|
}
|
|
@@ -11,12 +11,12 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
try {
|
|
14
|
-
const crypto = require('crypto');
|
|
15
14
|
const fs = require('fs');
|
|
16
|
-
const os = require('os');
|
|
17
15
|
const path = require('path');
|
|
18
16
|
|
|
19
17
|
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
18
|
+
const { projectRoot } = require('./lib/project-root.cjs');
|
|
19
|
+
const { reserveSession } = require('./lib/rules-reservation.cjs');
|
|
20
20
|
function readRuntime(cwd) {
|
|
21
21
|
try {
|
|
22
22
|
const p = runtimePath(cwd, 'runtime.json');
|
|
@@ -24,26 +24,6 @@ try {
|
|
|
24
24
|
} catch { return null; }
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/** Reserve one injection slot per session. Returns null on reservation errors. */
|
|
28
|
-
function reserveSession(sessionId) {
|
|
29
|
-
if (!sessionId) return null;
|
|
30
|
-
try {
|
|
31
|
-
const key = crypto.createHash('sha256')
|
|
32
|
-
.update(String(sessionId))
|
|
33
|
-
.digest('hex')
|
|
34
|
-
.slice(0, 16);
|
|
35
|
-
const file = path.join(os.tmpdir(), `cafekit-rules-${key}.json`);
|
|
36
|
-
fs.writeFileSync(file, JSON.stringify({ sessionId: String(sessionId) }), {
|
|
37
|
-
encoding: 'utf8',
|
|
38
|
-
flag: 'wx',
|
|
39
|
-
mode: 0o600
|
|
40
|
-
});
|
|
41
|
-
return true;
|
|
42
|
-
} catch (error) {
|
|
43
|
-
return error.code === 'EEXIST' ? false : null;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
27
|
// āā Main āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
48
28
|
|
|
49
29
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
@@ -52,22 +32,20 @@ try {
|
|
|
52
32
|
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
53
33
|
const payload = normalizeHookPayload(JSON.parse(stdin));
|
|
54
34
|
const sessionId = payload.session_id || null;
|
|
55
|
-
const
|
|
56
|
-
const
|
|
35
|
+
const root = projectRoot(payload);
|
|
36
|
+
const runtime = readRuntime(root);
|
|
37
|
+
if (runtime === null) process.exit(0);
|
|
57
38
|
|
|
58
39
|
// No session identity or reservation means no injection. Fail open.
|
|
59
40
|
if (reserveSession(sessionId) !== true) process.exit(0);
|
|
60
41
|
|
|
61
|
-
const runtime = readRuntime(cwd);
|
|
62
|
-
if (runtime === null) process.exit(0);
|
|
63
|
-
|
|
64
42
|
// Language
|
|
65
43
|
const thinkLang = runtime.locale?.thinkingLanguage || '';
|
|
66
44
|
const respondLang = runtime.locale?.responseLanguage || '';
|
|
67
45
|
const effectThink = thinkLang || (respondLang ? 'en' : '');
|
|
68
46
|
|
|
69
47
|
// Paths
|
|
70
|
-
const baseDir =
|
|
48
|
+
const baseDir = root;
|
|
71
49
|
const plansPath = path.join(baseDir, runtime.paths?.plans || 'plans');
|
|
72
50
|
const docsPath = path.join(baseDir, runtime.paths?.docs || 'docs');
|
|
73
51
|
const specsPath = path.join(baseDir, runtime.paths?.specs || 'specs');
|
|
@@ -11,11 +11,24 @@
|
|
|
11
11
|
* Exit: 0 always (fail-open)
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
function logCrash(error) {
|
|
15
|
+
try {
|
|
16
|
+
const fs = require('fs'), path = require('path');
|
|
17
|
+
const dir = require('./lib/hook-state-dir.cjs').hookStateDir();
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
fs.appendFileSync(path.join(dir, 'hook-log.jsonl'),
|
|
20
|
+
JSON.stringify({ ts: new Date().toISOString(), hook: 'session', status: 'crash', error: error.message }) + '\n');
|
|
21
|
+
} catch (_) {}
|
|
22
|
+
}
|
|
23
|
+
|
|
14
24
|
try {
|
|
15
25
|
const fs = require('fs');
|
|
16
26
|
const path = require('path');
|
|
17
27
|
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
18
28
|
const os = require('os');
|
|
29
|
+
const { projectRoot } = require('./lib/project-root.cjs');
|
|
30
|
+
const { checkCafeKitUpdate } = require('./lib/update-check.cjs');
|
|
31
|
+
const { clearSessionReservation } = require('./lib/rules-reservation.cjs');
|
|
19
32
|
const { execSync } = require('child_process');
|
|
20
33
|
|
|
21
34
|
// āā Utilities āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
@@ -41,21 +54,6 @@ try {
|
|
|
41
54
|
} catch { /* fail-open */ }
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
function projectRoot() {
|
|
45
|
-
const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string'
|
|
46
|
-
? process.env.CLAUDE_PROJECT_DIR.trim()
|
|
47
|
-
: '';
|
|
48
|
-
if (configured) {
|
|
49
|
-
try { return fs.realpathSync(path.resolve(configured)); } catch { /* continue */ }
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const installedRoot = path.resolve(__dirname, '..', '..');
|
|
53
|
-
const installedHook = runtimePath(installedRoot, 'hooks', path.basename(__filename));
|
|
54
|
-
if (fs.existsSync(installedHook)) return installedRoot;
|
|
55
|
-
|
|
56
|
-
try { return fs.realpathSync(process.cwd()); } catch { return path.resolve(process.cwd()); }
|
|
57
|
-
}
|
|
58
|
-
|
|
59
57
|
/** Read <runtime>/runtime.json config */
|
|
60
58
|
function readRuntime(cwd) {
|
|
61
59
|
try {
|
|
@@ -104,94 +102,6 @@ try {
|
|
|
104
102
|
return '';
|
|
105
103
|
}
|
|
106
104
|
|
|
107
|
-
// āā CafeKit Update Check āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Fetch the latest published version of @haposoft/cafekit from the npm registry.
|
|
111
|
-
* Returns a string (e.g. "0.11.2") or null on any failure. Timeout: 3 s.
|
|
112
|
-
*/
|
|
113
|
-
function fetchLatestVersion() {
|
|
114
|
-
try {
|
|
115
|
-
const https = require('https');
|
|
116
|
-
return new Promise((resolve) => {
|
|
117
|
-
const req = https.get(
|
|
118
|
-
'https://registry.npmjs.org/@haposoft/cafekit/latest',
|
|
119
|
-
{ timeout: 3000, headers: { Accept: 'application/json' } },
|
|
120
|
-
(res) => {
|
|
121
|
-
let body = '';
|
|
122
|
-
res.on('data', (chunk) => { body += chunk; });
|
|
123
|
-
res.on('end', () => {
|
|
124
|
-
try { resolve(JSON.parse(body).version || null); }
|
|
125
|
-
catch { resolve(null); }
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
);
|
|
129
|
-
req.on('error', () => resolve(null));
|
|
130
|
-
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
131
|
-
});
|
|
132
|
-
} catch { return Promise.resolve(null); }
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Simple semver compare: returns true when b > a. */
|
|
136
|
-
function isNewer(a, b) {
|
|
137
|
-
const n = (v) => String(v).split('.').map((x) => parseInt(x, 10) || 0);
|
|
138
|
-
const [aN, bN] = [n(a), n(b)];
|
|
139
|
-
for (let i = 0; i < 3; i++) {
|
|
140
|
-
if (bN[i] > aN[i]) return true;
|
|
141
|
-
if (bN[i] < aN[i]) return false;
|
|
142
|
-
}
|
|
143
|
-
return false;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Read the installed CafeKit version from <runtime>/cafekit.json.
|
|
148
|
-
* Returns null if not found.
|
|
149
|
-
*/
|
|
150
|
-
function getInstalledVersion(cwd) {
|
|
151
|
-
try {
|
|
152
|
-
const p = runtimePath(cwd, 'cafekit.json');
|
|
153
|
-
if (!fs.existsSync(p)) return null;
|
|
154
|
-
const meta = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
155
|
-
return typeof meta.version === 'string' ? meta.version : null;
|
|
156
|
-
} catch { return null; }
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Check for a newer CafeKit version and return an update notice string,
|
|
161
|
-
* or an empty string if already up to date or check fails.
|
|
162
|
-
* Result is cached in <runtime>/.cafekit-update-cache.json for 12 hours.
|
|
163
|
-
*/
|
|
164
|
-
async function checkCafeKitUpdate(cwd) {
|
|
165
|
-
const installed = getInstalledVersion(cwd);
|
|
166
|
-
if (!installed) return '';
|
|
167
|
-
|
|
168
|
-
const CACHE_TTL = 12 * 60 * 60 * 1000; // 12 hours
|
|
169
|
-
const cachePath = runtimePath(cwd, '.cafekit-update-cache.json');
|
|
170
|
-
|
|
171
|
-
// Return cached result if still fresh.
|
|
172
|
-
try {
|
|
173
|
-
if (fs.existsSync(cachePath)) {
|
|
174
|
-
const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
|
|
175
|
-
if (Date.now() - (cache.ts || 0) < CACHE_TTL) {
|
|
176
|
-
return cache.notice || '';
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
} catch { /* stale or corrupt ā refetch */ }
|
|
180
|
-
|
|
181
|
-
// Fetch from npm registry.
|
|
182
|
-
const latest = await fetchLatestVersion();
|
|
183
|
-
const notice = (latest && isNewer(installed, latest))
|
|
184
|
-
? `\nā” CafeKit update available: ${installed} ā ${latest}\n Run: npx @haposoft/cafekit\n`
|
|
185
|
-
: '';
|
|
186
|
-
|
|
187
|
-
// Persist cache (fail-open if write fails).
|
|
188
|
-
try {
|
|
189
|
-
fs.writeFileSync(cachePath, JSON.stringify({ ts: Date.now(), notice }), 'utf8');
|
|
190
|
-
} catch { /* fail-open */ }
|
|
191
|
-
|
|
192
|
-
return notice;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
105
|
// āā Main āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
196
106
|
|
|
197
107
|
(async () => {
|
|
@@ -199,12 +109,13 @@ try {
|
|
|
199
109
|
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
200
110
|
const payload = stdin ? normalizeHookPayload(JSON.parse(stdin)) : {};
|
|
201
111
|
const source = payload.source || 'unknown';
|
|
112
|
+
if (source === 'compact') clearSessionReservation(payload.session_id);
|
|
202
113
|
const envFile = process.env.CLAUDE_ENV_FILE;
|
|
203
|
-
const cwd = projectRoot();
|
|
114
|
+
const cwd = projectRoot(payload);
|
|
204
115
|
const runtime = readRuntime(cwd);
|
|
205
116
|
|
|
206
117
|
// Check CafeKit update in parallel with project detection (async, fail-open).
|
|
207
|
-
const updateCheckPromise = checkCafeKitUpdate(cwd);
|
|
118
|
+
const updateCheckPromise = checkCafeKitUpdate(runtimeDir(cwd)).catch(() => '');
|
|
208
119
|
|
|
209
120
|
// Project detection
|
|
210
121
|
const projectType = runtime.project?.type !== 'auto'
|
|
@@ -221,7 +132,6 @@ try {
|
|
|
221
132
|
|
|
222
133
|
// Static environment
|
|
223
134
|
const gitBranch = run('git branch --show-current', '', cwd);
|
|
224
|
-
const gitUrl = run('git remote get-url origin', '', cwd);
|
|
225
135
|
const gitRoot = run('git rev-parse --show-toplevel', '', cwd);
|
|
226
136
|
const nodeVer = process.version;
|
|
227
137
|
const pythonVer = run('python3 --version', '', cwd) || run('python --version', '', cwd);
|
|
@@ -235,7 +145,6 @@ try {
|
|
|
235
145
|
writeEnv(envFile, 'PACKAGE_MANAGER', packageManager);
|
|
236
146
|
writeEnv(envFile, 'FRAMEWORK', framework);
|
|
237
147
|
writeEnv(envFile, 'GIT_BRANCH', gitBranch);
|
|
238
|
-
writeEnv(envFile, 'GIT_URL', gitUrl);
|
|
239
148
|
writeEnv(envFile, 'GIT_ROOT', gitRoot);
|
|
240
149
|
writeEnv(envFile, 'NODE_VERSION', nodeVer);
|
|
241
150
|
writeEnv(envFile, 'PYTHON_VERSION', pythonVer);
|
|
@@ -262,6 +171,14 @@ try {
|
|
|
262
171
|
|
|
263
172
|
console.log(`Session ${source}. ${parts.length ? parts.join(' | ') : 'No project info detected.'}${updateNotice}`);
|
|
264
173
|
|
|
174
|
+
if (typeof payload.cwd === 'string' && payload.cwd) {
|
|
175
|
+
let sessionCwd = path.resolve(payload.cwd);
|
|
176
|
+
try { sessionCwd = fs.realpathSync(sessionCwd); } catch { /* lexical fallback */ }
|
|
177
|
+
if (sessionCwd !== cwd) {
|
|
178
|
+
console.log(`CafeKit project root: ${cwd} (session working directory: ${sessionCwd}).`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
265
182
|
// Compact warning ā context compaction can lose pending approval state
|
|
266
183
|
if (source === 'compact') {
|
|
267
184
|
console.log('\nšØ SESSION COMPRESSED ā VERIFY PENDING AUTHORIZATIONS:');
|
|
@@ -274,8 +191,11 @@ try {
|
|
|
274
191
|
// bearings, and nothing more.
|
|
275
192
|
try {
|
|
276
193
|
const fs2 = require('fs'), p2 = require('path');
|
|
277
|
-
|
|
194
|
+
if (typeof payload.session_id !== 'string' || !payload.session_id.trim()) process.exit(0);
|
|
195
|
+
const key = require('crypto').createHash('sha256').update(payload.session_id).digest('hex');
|
|
196
|
+
const file = p2.join(require('./lib/hook-state-dir.cjs').hookStateDir(), `compact-recovery-${key}.json`);
|
|
278
197
|
const record = JSON.parse(fs2.readFileSync(file, 'utf8'));
|
|
198
|
+
if (record.session_id !== payload.session_id) process.exit(0);
|
|
279
199
|
const parts = [];
|
|
280
200
|
if (record.branch) parts.push(`branch ${record.branch}`);
|
|
281
201
|
if (record.head) parts.push(`HEAD ${record.head}`);
|
|
@@ -297,15 +217,12 @@ try {
|
|
|
297
217
|
}
|
|
298
218
|
|
|
299
219
|
process.exit(0);
|
|
300
|
-
})()
|
|
220
|
+
})().catch((error) => {
|
|
221
|
+
logCrash(error);
|
|
222
|
+
process.exit(0);
|
|
223
|
+
});
|
|
301
224
|
|
|
302
|
-
} catch (
|
|
303
|
-
|
|
304
|
-
const fs = require('fs'), p = require('path');
|
|
305
|
-
const d = require('./lib/hook-state-dir.cjs').hookStateDir();
|
|
306
|
-
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
307
|
-
fs.appendFileSync(p.join(d, 'hook-log.jsonl'),
|
|
308
|
-
JSON.stringify({ ts: new Date().toISOString(), hook: 'session', status: 'crash', error: e.message }) + '\n');
|
|
309
|
-
} catch (_) {}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
logCrash(error);
|
|
310
227
|
process.exit(0);
|
|
311
228
|
}
|
|
@@ -16,32 +16,7 @@
|
|
|
16
16
|
const fs = require('fs');
|
|
17
17
|
const path = require('path');
|
|
18
18
|
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
19
|
-
|
|
20
|
-
function projectRoot(payload = {}) {
|
|
21
|
-
const configured = typeof process.env.CLAUDE_PROJECT_DIR === 'string'
|
|
22
|
-
? process.env.CLAUDE_PROJECT_DIR.trim()
|
|
23
|
-
: '';
|
|
24
|
-
if (configured) {
|
|
25
|
-
try { return fs.realpathSync(path.resolve(configured)); } catch { /* continue */ }
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const installedRoot = path.resolve(__dirname, '..', '..');
|
|
29
|
-
const installedHook = runtimePath(installedRoot, 'hooks', path.basename(__filename));
|
|
30
|
-
if (fs.existsSync(installedHook)) return installedRoot;
|
|
31
|
-
|
|
32
|
-
const sourceFixture = typeof process.env.PROJECT_ROOT === 'string'
|
|
33
|
-
? process.env.PROJECT_ROOT.trim()
|
|
34
|
-
: '';
|
|
35
|
-
if (sourceFixture) {
|
|
36
|
-
try { return fs.realpathSync(path.resolve(sourceFixture)); } catch { /* continue */ }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const legacy = typeof payload.cwd === 'string' ? payload.cwd.trim() : '';
|
|
40
|
-
if (legacy) {
|
|
41
|
-
try { return fs.realpathSync(path.resolve(legacy)); } catch { /* continue */ }
|
|
42
|
-
}
|
|
43
|
-
try { return fs.realpathSync(process.cwd()); } catch { return path.resolve(process.cwd()); }
|
|
44
|
-
}
|
|
19
|
+
const { projectRoot } = require('./lib/project-root.cjs');
|
|
45
20
|
|
|
46
21
|
function logCrash(error) {
|
|
47
22
|
try {
|
|
@@ -68,6 +43,7 @@ function sessionIdentity(payload) {
|
|
|
68
43
|
return candidates.find((value) => typeof value === 'string' && value.trim() !== '') || null;
|
|
69
44
|
}
|
|
70
45
|
|
|
46
|
+
let hasDoneTasks = false;
|
|
71
47
|
try {
|
|
72
48
|
const policyPath = path.join(__dirname, '..', 'scripts', 'workflow-policy.cjs');
|
|
73
49
|
|
|
@@ -132,6 +108,8 @@ try {
|
|
|
132
108
|
}
|
|
133
109
|
if (!resolved) process.exit(0);
|
|
134
110
|
if (resolved.layoutKind === 'process-v3-completed-set') {
|
|
111
|
+
hasDoneTasks = resolved.candidates.some(candidate =>
|
|
112
|
+
Object.values(candidate.taskRegistry || {}).some(task => task?.status === 'done'));
|
|
135
113
|
const configuredGate = runtime.spec?.completion_gate;
|
|
136
114
|
if (configuredGate !== undefined && configuredGate !== true) {
|
|
137
115
|
emitBlock('Completion gate: runtime.spec.completion_gate is a worker-writable flag, not an authorization; no completion-gate bypass is supported. Remove the flag and satisfy the gate.');
|
|
@@ -178,6 +156,7 @@ try {
|
|
|
178
156
|
|
|
179
157
|
const featureName = resolved.featureName;
|
|
180
158
|
const specsPath = resolved.specsDir;
|
|
159
|
+
hasDoneTasks = Object.values(resolved.taskRegistry || {}).some(task => task?.status === 'done');
|
|
181
160
|
const runtimeContext = POLICY.deriveRuntimeContext({
|
|
182
161
|
projectRoot: baseDir,
|
|
183
162
|
specsRoot: specsPath,
|
|
@@ -250,6 +229,10 @@ try {
|
|
|
250
229
|
process.exit(0);
|
|
251
230
|
|
|
252
231
|
} catch (e) {
|
|
232
|
+
if (e.code === 'no_git_history') {
|
|
233
|
+
if (hasDoneTasks) emitBlock('Completion gate: repo has no commits yet; create the first commit, then record Base/Head after it.');
|
|
234
|
+
process.exit(0);
|
|
235
|
+
}
|
|
253
236
|
logCrash(e);
|
|
254
237
|
emitBlock(`Completion gate controlled failure: ${e.message}. Completion is blocked until the hook is repaired.`);
|
|
255
238
|
process.exit(0);
|