@cleocode/adapters 2026.10.8 → 2026.10.9
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/dist/heavy-command-hook.js +13 -12
- package/dist/heavy-command-hook.js.map +1 -1
- package/dist/index.js +60 -133
- package/dist/index.js.map +2 -2
- package/dist/project-hook-delivery.d.ts +6 -0
- package/dist/project-hook-delivery.d.ts.map +1 -0
- package/dist/project-hook-delivery.js +527 -0
- package/dist/project-hook-delivery.js.map +6 -0
- package/dist/project-hook-native.d.ts +7 -0
- package/dist/project-hook-native.d.ts.map +1 -0
- package/dist/project-hook-native.js +84 -0
- package/dist/project-hook-native.js.map +6 -0
- package/dist/providers/claude-code/hooks.d.ts.map +1 -1
- package/dist/providers/codex/hooks.d.ts +5 -4
- package/dist/providers/codex/hooks.d.ts.map +1 -1
- package/dist/providers/cursor/hooks.d.ts.map +1 -1
- package/dist/providers/gemini-cli/hooks.d.ts.map +1 -1
- package/dist/providers/opencode/hooks.d.ts.map +1 -1
- package/dist/providers/pi/hooks.d.ts.map +1 -1
- package/dist/providers/shared/heavy-command-hook-install.d.ts +2 -1
- package/dist/providers/shared/heavy-command-hook-install.d.ts.map +1 -1
- package/package.json +14 -5
- package/src/__tests__/project-hook-delivery.test.ts +319 -0
- package/src/project-hook-delivery.ts +532 -0
- package/src/project-hook-native.ts +128 -0
- package/src/providers/claude-code/hooks.ts +2 -30
- package/src/providers/codex/hooks.ts +7 -9
- package/src/providers/cursor/hooks.ts +2 -22
- package/src/providers/gemini-cli/hooks.ts +2 -12
- package/src/providers/opencode/hooks.ts +2 -22
- package/src/providers/pi/hooks.ts +2 -26
- package/src/providers/shared/heavy-command-hook-install.ts +21 -10
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/** Cold-path, project-local provider delivery of activated shared checks (T13344). */
|
|
2
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { lstat, realpath, unlink } from 'node:fs/promises';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
editJsonConfigFile,
|
|
10
|
+
readManagedJsonConfigFile,
|
|
11
|
+
withFileLock,
|
|
12
|
+
writeFileAtomic,
|
|
13
|
+
} from '@cleocode/caamp/atomic';
|
|
14
|
+
import { GENERATED_PROVIDER_HOOK_PROFILES } from '@cleocode/caamp/hooks';
|
|
15
|
+
import type { HookJsonValue } from '@cleocode/contracts/heavy-command-hook.js';
|
|
16
|
+
import {
|
|
17
|
+
type HookConfigEdit,
|
|
18
|
+
type HookConfigObject,
|
|
19
|
+
type ProjectHookDeliveryOptions,
|
|
20
|
+
type ProjectHookDeliveryReceipt,
|
|
21
|
+
ProjectHookDeliveryReceiptSchema,
|
|
22
|
+
type ProjectHookDeliveryResult,
|
|
23
|
+
type ProjectHookDeliveryServices,
|
|
24
|
+
type ProjectHookEntryInspection,
|
|
25
|
+
} from '@cleocode/contracts/project-hook-delivery.js';
|
|
26
|
+
import { excludeHookFileFromGit } from './providers/shared/heavy-command-hook-install.js';
|
|
27
|
+
|
|
28
|
+
const MARKER = '# cleo-project-hook:v1';
|
|
29
|
+
const BINARIES: Record<string, string> = {
|
|
30
|
+
'claude-code': 'claude',
|
|
31
|
+
codex: 'codex',
|
|
32
|
+
opencode: 'opencode',
|
|
33
|
+
kimi: 'kimi',
|
|
34
|
+
};
|
|
35
|
+
const hash = (value: string): string => createHash('sha256').update(value).digest('hex');
|
|
36
|
+
const object = (value: HookJsonValue | undefined): value is HookConfigObject =>
|
|
37
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
38
|
+
|
|
39
|
+
function probeVersion(provider: string): string | undefined {
|
|
40
|
+
const binary = BINARIES[provider];
|
|
41
|
+
if (!binary) return undefined;
|
|
42
|
+
try {
|
|
43
|
+
return execFileSync(binary, ['--version'], {
|
|
44
|
+
encoding: 'utf8',
|
|
45
|
+
timeout: 2000,
|
|
46
|
+
maxBuffer: 8192,
|
|
47
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
48
|
+
}).match(/\d+\.\d+\.\d+/)?.[0];
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolve an existing ancestor and refuse provider config symlink escapes. */
|
|
55
|
+
async function localTarget(root: string, path: string): Promise<string> {
|
|
56
|
+
if ((await realpath(root)) === (await realpath(homedir())))
|
|
57
|
+
throw new Error('HOOK_GLOBAL_CONFIG_FORBIDDEN');
|
|
58
|
+
if (isAbsolute(path) || path.split(/[\\/]/).includes('..'))
|
|
59
|
+
throw new Error('HOOK_CONFIG_PATH_ESCAPE');
|
|
60
|
+
const target = resolve(root, path);
|
|
61
|
+
let ancestor = target;
|
|
62
|
+
while (!existsSync(ancestor)) ancestor = dirname(ancestor);
|
|
63
|
+
const resolved = await realpath(ancestor);
|
|
64
|
+
const rel = relative(await realpath(root), resolved);
|
|
65
|
+
if (rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
66
|
+
throw new Error('HOOK_CONFIG_PATH_ESCAPE');
|
|
67
|
+
if (existsSync(target) && (await lstat(target)).isSymbolicLink())
|
|
68
|
+
throw new Error('HOOK_CONFIG_SYMLINK');
|
|
69
|
+
return target;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function command(provider: string, event: string): string {
|
|
73
|
+
// Only registry IDs and canonical event identifiers are interpolated; never project paths.
|
|
74
|
+
const invocation = 'cleo hook run --source agent --provider ' + provider + ' --event ' + event;
|
|
75
|
+
return (
|
|
76
|
+
'if command -v cleo >/dev/null 2>&1 && [ "$(cleo hook run --probe </dev/null 2>/dev/null)" = "CLEO_PROJECT_HOOK_V1" ]; then ' +
|
|
77
|
+
invocation +
|
|
78
|
+
'; else printf "%s\\n" "[cleo hook] project runner unavailable; operation allowed. Run cleo doctor hooks." >&2; fi # cleo-hook ' +
|
|
79
|
+
MARKER
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function desiredEntries(provider: string, events: string[]): ProjectHookDeliveryReceipt['entries'] {
|
|
84
|
+
const profile = GENERATED_PROVIDER_HOOK_PROFILES[provider];
|
|
85
|
+
const result: ProjectHookDeliveryReceipt['entries'] = [];
|
|
86
|
+
for (const canonical of new Set(events)) {
|
|
87
|
+
const mapping = Object.entries(profile?.mappings ?? {}).find(
|
|
88
|
+
([event]) => event === canonical,
|
|
89
|
+
)?.[1];
|
|
90
|
+
if (!mapping?.supported || !mapping.nativeName || !/^[A-Za-z][A-Za-z0-9]*$/.test(canonical))
|
|
91
|
+
continue;
|
|
92
|
+
const cmd = command(provider, canonical);
|
|
93
|
+
result.push({
|
|
94
|
+
event: mapping.nativeName,
|
|
95
|
+
command: cmd,
|
|
96
|
+
hash: hash(JSON.stringify(entry(cmd))),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function entry(cmd: string, timeout: number | null = 130): HookJsonValue {
|
|
103
|
+
return {
|
|
104
|
+
matcher: '',
|
|
105
|
+
hooks: [{ type: 'command', command: cmd, ...(timeout === null ? {} : { timeout }) }],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function receiptAt(
|
|
110
|
+
path: string,
|
|
111
|
+
services: ProjectHookDeliveryServices,
|
|
112
|
+
): Promise<ProjectHookDeliveryReceipt | undefined> {
|
|
113
|
+
try {
|
|
114
|
+
const info = await lstat(path);
|
|
115
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 262144)
|
|
116
|
+
throw new Error('HOOK_RECEIPT_UNSAFE');
|
|
117
|
+
return ProjectHookDeliveryReceiptSchema.parse(
|
|
118
|
+
JSON.parse(await services.readTextFile(path, 262144)),
|
|
119
|
+
);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return undefined;
|
|
122
|
+
throw new Error('HOOK_RECEIPT_INVALID');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function entryCommand(value: HookJsonValue): string | undefined {
|
|
127
|
+
if (!object(value) || !Array.isArray(value.hooks) || value.hooks.length !== 1) return undefined;
|
|
128
|
+
const hook = value.hooks[0];
|
|
129
|
+
return object(hook) && typeof hook.command === 'string' ? hook.command : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function inspectEntries(
|
|
133
|
+
config: HookConfigObject,
|
|
134
|
+
receipt: ProjectHookDeliveryReceipt | undefined,
|
|
135
|
+
): ProjectHookEntryInspection[] {
|
|
136
|
+
const result: ProjectHookEntryInspection[] = [];
|
|
137
|
+
if (!object(config.hooks)) return result;
|
|
138
|
+
for (const [event, entries] of Object.entries(config.hooks)) {
|
|
139
|
+
if (!Array.isArray(entries)) continue;
|
|
140
|
+
for (const [index, value] of entries.entries()) {
|
|
141
|
+
const digest = hash(JSON.stringify(value));
|
|
142
|
+
const owned = receipt?.entries.some((item) => item.event === event && item.hash === digest);
|
|
143
|
+
result.push({
|
|
144
|
+
event,
|
|
145
|
+
index,
|
|
146
|
+
hash: digest,
|
|
147
|
+
owner: owned ? 'project' : 'unknown',
|
|
148
|
+
provenance: owned ? 'cleo-receipt' : 'unknown',
|
|
149
|
+
...(owned ? { source: '.cleo/hooks.json' } : {}),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return result;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function configEdits(
|
|
157
|
+
config: HookConfigObject,
|
|
158
|
+
prior: ProjectHookDeliveryReceipt | undefined,
|
|
159
|
+
desired: ProjectHookDeliveryReceipt['entries'],
|
|
160
|
+
): HookConfigEdit[] {
|
|
161
|
+
if (config.hooks !== undefined && !object(config.hooks))
|
|
162
|
+
throw new Error('HOOK_CONFIG_HOOKS_SHAPE');
|
|
163
|
+
const hooks = object(config.hooks) ? config.hooks : {};
|
|
164
|
+
const edits: HookConfigEdit[] = [];
|
|
165
|
+
const found = new Set<string>();
|
|
166
|
+
const retained = new Set<string>();
|
|
167
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
168
|
+
if (!Array.isArray(entries)) throw new Error('HOOK_CONFIG_EVENT_SHAPE');
|
|
169
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
170
|
+
const value = entries[index];
|
|
171
|
+
if (value === undefined) continue;
|
|
172
|
+
const cmd = entryCommand(value);
|
|
173
|
+
const owns = prior?.entries.find((old) => old.event === event && old.command === cmd);
|
|
174
|
+
const hasMarker = JSON.stringify(value).includes(MARKER);
|
|
175
|
+
if (!owns && hasMarker) throw new Error('HOOK_CONFIG_UNATTRIBUTED_ENTRY');
|
|
176
|
+
if (!owns) continue;
|
|
177
|
+
if (hash(JSON.stringify(value)) !== owns.hash || found.has(event + ':' + cmd))
|
|
178
|
+
throw new Error('HOOK_CONFIG_MANAGED_DRIFT');
|
|
179
|
+
found.add(event + ':' + cmd);
|
|
180
|
+
if (desired.some((next) => next.event === event && next.hash === owns.hash))
|
|
181
|
+
retained.add(event + ':' + cmd);
|
|
182
|
+
else edits.push({ path: ['hooks', event, index] });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const next of desired) {
|
|
186
|
+
const entries = hooks[next.event];
|
|
187
|
+
if (entries !== undefined && !Array.isArray(entries))
|
|
188
|
+
throw new Error('HOOK_CONFIG_EVENT_SHAPE');
|
|
189
|
+
if (retained.has(next.event + ':' + next.command)) continue;
|
|
190
|
+
const removals = edits.filter((change) => change.path[1] === next.event).length;
|
|
191
|
+
edits.push(
|
|
192
|
+
Array.isArray(entries)
|
|
193
|
+
? {
|
|
194
|
+
path: ['hooks', next.event, entries.length - removals],
|
|
195
|
+
value: entry(next.command),
|
|
196
|
+
insert: true,
|
|
197
|
+
}
|
|
198
|
+
: { path: ['hooks', next.event], value: [entry(next.command)] },
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return edits;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pluginSource(events: string[]): string {
|
|
205
|
+
if (!events.includes('PreToolUse'))
|
|
206
|
+
return '// CLEO project hooks v1; no supported bindings.\nexport default async () => ({});\n';
|
|
207
|
+
return [
|
|
208
|
+
'// CLEO project hooks v1; managed by a local hash receipt.',
|
|
209
|
+
'import { execFile } from "node:child_process";',
|
|
210
|
+
'export const CleoProjectHooks = async ({directory}) => {',
|
|
211
|
+
' const contexts = new Map();',
|
|
212
|
+
' return {',
|
|
213
|
+
' "tool.execute.before": async (input, output) => {',
|
|
214
|
+
' const context = await new Promise(done => {',
|
|
215
|
+
' const child = execFile("cleo", ["hook","run","--source","agent","--provider","opencode","--event","PreToolUse"], {cwd:directory, timeout:130000, maxBuffer:32768}, (error, stdout, stderr) => {',
|
|
216
|
+
' if(error) {done("CLEO project hook unavailable; operation allowed. Run cleo doctor hooks."); return}',
|
|
217
|
+
' try {const answer=JSON.parse(stdout || "{}"); done(typeof answer.context === "string" ? answer.context : stderr ? "CLEO project hook infrastructure warning; operation allowed. Run cleo doctor hooks." : "")} catch {done("CLEO project hook response unavailable; operation allowed. Run cleo doctor hooks.")}',
|
|
218
|
+
' });',
|
|
219
|
+
' child.on("error", () => done("CLEO project hook unavailable; operation allowed. Run cleo doctor hooks."));',
|
|
220
|
+
' child.stdin?.on("error", () => {}); child.stdin?.end(JSON.stringify({tool_name:input.tool,tool_input:output.args,cwd:directory}));',
|
|
221
|
+
' });',
|
|
222
|
+
' if(context && input.callID) {if(contexts.size >= 128) contexts.delete(contexts.keys().next().value); contexts.set(input.callID, context.slice(0,8192))}',
|
|
223
|
+
' },',
|
|
224
|
+
' "tool.execute.after": async (input, output) => {',
|
|
225
|
+
' const context=contexts.get(input.callID); contexts.delete(input.callID);',
|
|
226
|
+
' if(context && typeof output.output === "string") output.output += "\\n\\n" + context;',
|
|
227
|
+
' },',
|
|
228
|
+
' };',
|
|
229
|
+
'};',
|
|
230
|
+
'',
|
|
231
|
+
].join('\n');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function deliverProvider(
|
|
235
|
+
options: ProjectHookDeliveryOptions,
|
|
236
|
+
provider: string,
|
|
237
|
+
): Promise<ProjectHookDeliveryResult> {
|
|
238
|
+
const profile = GENERATED_PROVIDER_HOOK_PROFILES[provider];
|
|
239
|
+
const capability = profile?.projectDelivery;
|
|
240
|
+
const version = probeVersion(provider);
|
|
241
|
+
const result: ProjectHookDeliveryResult = {
|
|
242
|
+
provider,
|
|
243
|
+
version,
|
|
244
|
+
state: 'unsupported',
|
|
245
|
+
capability:
|
|
246
|
+
version && capability?.verifiedVersions.includes(version)
|
|
247
|
+
? 'verified'
|
|
248
|
+
: version && capability?.documentedVersions.includes(version)
|
|
249
|
+
? 'documented'
|
|
250
|
+
: 'unverified',
|
|
251
|
+
nativeTrust: 'unverified',
|
|
252
|
+
provenance: 'unknown',
|
|
253
|
+
diagnostics: [],
|
|
254
|
+
};
|
|
255
|
+
if (result.capability !== 'verified') result.diagnostics.push('HOOK_LIVE_DELIVERY_UNVERIFIED');
|
|
256
|
+
if (!capability?.configPath) {
|
|
257
|
+
result.diagnostics.push('HOOK_PROJECT_LOCAL_UNSUPPORTED');
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
const configPath = capability.configPath;
|
|
261
|
+
const target = await localTarget(options.projectRoot, configPath);
|
|
262
|
+
result.configPath = target;
|
|
263
|
+
const receiptPath = join(options.stateDir, 'provider-' + provider + '.json');
|
|
264
|
+
const prior = await receiptAt(receiptPath, options.services);
|
|
265
|
+
if (prior && (prior.provider !== provider || prior.configPath !== target))
|
|
266
|
+
throw new Error('HOOK_RECEIPT_PATH_MISMATCH');
|
|
267
|
+
if (prior) {
|
|
268
|
+
const allowed = desiredEntries(provider, Object.keys(profile.mappings));
|
|
269
|
+
// Exact former v1 generator output is known; never derive ownership from a marker alone.
|
|
270
|
+
for (const [canonical, mapping] of Object.entries(profile.mappings)) {
|
|
271
|
+
if (!mapping?.supported || !mapping.nativeName) continue;
|
|
272
|
+
const former =
|
|
273
|
+
'cleo hook run --source agent --provider ' +
|
|
274
|
+
provider +
|
|
275
|
+
' --event ' +
|
|
276
|
+
canonical +
|
|
277
|
+
' # cleo-hook ' +
|
|
278
|
+
MARKER;
|
|
279
|
+
for (const generated of [former, command(provider, canonical)]) {
|
|
280
|
+
allowed.push({
|
|
281
|
+
event: mapping.nativeName,
|
|
282
|
+
command: generated,
|
|
283
|
+
hash: hash(JSON.stringify(entry(generated, null))),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const valid =
|
|
288
|
+
profile.hookSystem === 'plugin'
|
|
289
|
+
? prior.entries.length === 1 &&
|
|
290
|
+
prior.entries[0]?.event === 'plugin' &&
|
|
291
|
+
prior.entries[0]?.command === 'cleo hook run' &&
|
|
292
|
+
[pluginSource([]), pluginSource(['PreToolUse'])].some(
|
|
293
|
+
(body) => hash(body) === prior.entries[0]?.hash,
|
|
294
|
+
)
|
|
295
|
+
: prior.entries.every((item) =>
|
|
296
|
+
allowed.some(
|
|
297
|
+
(candidate) =>
|
|
298
|
+
candidate.event === item.event &&
|
|
299
|
+
candidate.command === item.command &&
|
|
300
|
+
candidate.hash === item.hash,
|
|
301
|
+
),
|
|
302
|
+
);
|
|
303
|
+
if (!valid) throw new Error('HOOK_RECEIPT_OWNERSHIP_INVALID');
|
|
304
|
+
result.provenance = 'cleo-receipt';
|
|
305
|
+
}
|
|
306
|
+
if (options.events.length === 0 && !prior && !options.rollback) {
|
|
307
|
+
result.state = 'unsupported';
|
|
308
|
+
result.diagnostics.push('HOOK_NO_AGENT_BINDINGS');
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
if (!options.enabled && !options.rollback) {
|
|
312
|
+
result.state = 'disabled';
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
const desired = options.rollback ? [] : desiredEntries(provider, options.events);
|
|
316
|
+
const tracking = spawnSync(
|
|
317
|
+
'git',
|
|
318
|
+
['-C', options.projectRoot, 'ls-files', '--error-unmatch', '--', capability.configPath],
|
|
319
|
+
{ timeout: 2000, stdio: 'ignore', env: options.services.gitEnvironment },
|
|
320
|
+
);
|
|
321
|
+
if (tracking.status === 0) {
|
|
322
|
+
result.state = 'conflict';
|
|
323
|
+
result.diagnostics.push('HOOK_CONFIG_TRACKED_REVIEW_REQUIRED');
|
|
324
|
+
result.integrationSnippet = JSON.stringify(
|
|
325
|
+
{ hooks: Object.fromEntries(desired.map((item) => [item.event, [entry(item.command)]])) },
|
|
326
|
+
null,
|
|
327
|
+
2,
|
|
328
|
+
);
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
if (tracking.error || tracking.signal || tracking.status !== 1)
|
|
332
|
+
throw new Error('HOOK_GIT_TRACKING_UNVERIFIED');
|
|
333
|
+
for (const event of options.events) {
|
|
334
|
+
if (!desired.some((item) => item.command === command(provider, event)))
|
|
335
|
+
result.diagnostics.push('HOOK_EVENT_UNSUPPORTED:' + event);
|
|
336
|
+
}
|
|
337
|
+
if (profile.hookSystem === 'plugin') {
|
|
338
|
+
for (const event of options.events.filter((event) => event !== 'PreToolUse'))
|
|
339
|
+
result.diagnostics.push('HOOK_PLUGIN_EVENT_UNSUPPORTED:' + event);
|
|
340
|
+
if (!options.rollback && !options.events.includes('PreToolUse')) {
|
|
341
|
+
result.state = 'unsupported';
|
|
342
|
+
return result;
|
|
343
|
+
}
|
|
344
|
+
const body = pluginSource(options.events);
|
|
345
|
+
const existing = existsSync(target)
|
|
346
|
+
? await options.services.readTextFile(target, 262144)
|
|
347
|
+
: undefined;
|
|
348
|
+
if (existing !== undefined && (!prior || prior.entries[0]?.hash !== hash(existing))) {
|
|
349
|
+
result.state = 'conflict';
|
|
350
|
+
result.diagnostics.push('HOOK_CONFIG_MANAGED_DRIFT');
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
353
|
+
if (options.rollback) {
|
|
354
|
+
if (existing !== undefined && !options.dryRun)
|
|
355
|
+
await withFileLock(target, async () => {
|
|
356
|
+
await localTarget(options.projectRoot, configPath);
|
|
357
|
+
const current = existsSync(target)
|
|
358
|
+
? await options.services.readTextFile(target, 262144)
|
|
359
|
+
: undefined;
|
|
360
|
+
if (current !== existing) throw new Error('HOOK_CONFIG_CONCURRENT_EDIT');
|
|
361
|
+
await unlink(target);
|
|
362
|
+
});
|
|
363
|
+
result.state = 'disabled';
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
result.state = existing === body ? 'current' : 'planned';
|
|
367
|
+
if (!options.dryRun && existing !== body) {
|
|
368
|
+
if (existing === undefined)
|
|
369
|
+
excludeHookFileFromGit(
|
|
370
|
+
options.projectRoot,
|
|
371
|
+
capability.configPath,
|
|
372
|
+
undefined,
|
|
373
|
+
options.services.gitEnvironment,
|
|
374
|
+
);
|
|
375
|
+
await withFileLock(target, async () => {
|
|
376
|
+
await localTarget(options.projectRoot, configPath);
|
|
377
|
+
const current = existsSync(target)
|
|
378
|
+
? await options.services.readTextFile(target, 262144)
|
|
379
|
+
: undefined;
|
|
380
|
+
if (current !== existing) throw new Error('HOOK_CONFIG_CONCURRENT_EDIT');
|
|
381
|
+
await writeFileAtomic({ path: target, content: body });
|
|
382
|
+
});
|
|
383
|
+
await writeFileAtomic({
|
|
384
|
+
path: receiptPath,
|
|
385
|
+
content:
|
|
386
|
+
JSON.stringify({
|
|
387
|
+
schemaVersion: 1,
|
|
388
|
+
provider,
|
|
389
|
+
configPath: target,
|
|
390
|
+
entries: [{ event: 'plugin', command: 'cleo hook run', hash: hash(body) }],
|
|
391
|
+
recordedAt: new Date().toISOString(),
|
|
392
|
+
}) + '\n',
|
|
393
|
+
});
|
|
394
|
+
result.state = 'installed';
|
|
395
|
+
result.provenance = 'cleo-receipt';
|
|
396
|
+
}
|
|
397
|
+
return result;
|
|
398
|
+
}
|
|
399
|
+
const config = await readManagedJsonConfigFile(target);
|
|
400
|
+
result.entries = inspectEntries(config, prior);
|
|
401
|
+
const edits = configEdits(config, prior, desired);
|
|
402
|
+
result.state = edits.length ? 'planned' : 'current';
|
|
403
|
+
if (!options.dryRun) {
|
|
404
|
+
if (!existsSync(target))
|
|
405
|
+
excludeHookFileFromGit(
|
|
406
|
+
options.projectRoot,
|
|
407
|
+
capability.configPath,
|
|
408
|
+
undefined,
|
|
409
|
+
options.services.gitEnvironment,
|
|
410
|
+
);
|
|
411
|
+
await editJsonConfigFile(target, (current) => configEdits(current, prior, desired));
|
|
412
|
+
await writeFileAtomic({
|
|
413
|
+
path: receiptPath,
|
|
414
|
+
content:
|
|
415
|
+
JSON.stringify({
|
|
416
|
+
schemaVersion: 1,
|
|
417
|
+
provider,
|
|
418
|
+
configPath: target,
|
|
419
|
+
entries: desired,
|
|
420
|
+
recordedAt: new Date().toISOString(),
|
|
421
|
+
}) + '\n',
|
|
422
|
+
});
|
|
423
|
+
result.state = edits.length ? 'installed' : 'current';
|
|
424
|
+
result.provenance = 'cleo-receipt';
|
|
425
|
+
result.entries = inspectEntries(
|
|
426
|
+
await readManagedJsonConfigFile(target),
|
|
427
|
+
await receiptAt(receiptPath, options.services),
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
return result;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Report unsupported bindings even when the same provider delivered other eligible events. */
|
|
434
|
+
export function hasUnsupportedProjectHookDelivery(
|
|
435
|
+
results: readonly ProjectHookDeliveryResult[],
|
|
436
|
+
): boolean {
|
|
437
|
+
return results.some(
|
|
438
|
+
(result) =>
|
|
439
|
+
result.state === 'unsupported' ||
|
|
440
|
+
result.diagnostics.some(
|
|
441
|
+
(code) =>
|
|
442
|
+
code.startsWith('HOOK_EVENT_UNSUPPORTED:') ||
|
|
443
|
+
code.startsWith('HOOK_PLUGIN_EVENT_UNSUPPORTED:'),
|
|
444
|
+
),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function validateStateDirectory(options: ProjectHookDeliveryOptions): Promise<void> {
|
|
449
|
+
const gitState = resolve(
|
|
450
|
+
options.projectRoot,
|
|
451
|
+
execFileSync(
|
|
452
|
+
'git',
|
|
453
|
+
[
|
|
454
|
+
'-C',
|
|
455
|
+
options.projectRoot,
|
|
456
|
+
'rev-parse',
|
|
457
|
+
'--path-format=absolute',
|
|
458
|
+
'--git-path',
|
|
459
|
+
'cleo-project-hooks',
|
|
460
|
+
],
|
|
461
|
+
{
|
|
462
|
+
encoding: 'utf8',
|
|
463
|
+
timeout: 2000,
|
|
464
|
+
maxBuffer: 16384,
|
|
465
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
466
|
+
env: options.services.gitEnvironment,
|
|
467
|
+
},
|
|
468
|
+
).trim(),
|
|
469
|
+
);
|
|
470
|
+
const parent = await realpath(dirname(gitState));
|
|
471
|
+
if (
|
|
472
|
+
resolve(options.stateDir) !== gitState ||
|
|
473
|
+
(await realpath(dirname(resolve(options.stateDir)))) !== parent
|
|
474
|
+
)
|
|
475
|
+
throw new Error('HOOK_STATE_DIRECTORY_INVALID');
|
|
476
|
+
if (
|
|
477
|
+
existsSync(gitState) &&
|
|
478
|
+
(!(await lstat(gitState)).isDirectory() || (await lstat(gitState)).isSymbolicLink())
|
|
479
|
+
)
|
|
480
|
+
throw new Error('HOOK_STATE_DIRECTORY_INVALID');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Inspect or sync only project-local entries; no provider trust settings are written. */
|
|
484
|
+
export async function syncProjectHookProviders(
|
|
485
|
+
options: ProjectHookDeliveryOptions,
|
|
486
|
+
): Promise<ProjectHookDeliveryResult[]> {
|
|
487
|
+
const providers =
|
|
488
|
+
options.providers ??
|
|
489
|
+
Object.entries(GENERATED_PROVIDER_HOOK_PROFILES)
|
|
490
|
+
.filter(
|
|
491
|
+
([id, profile]) =>
|
|
492
|
+
profile.projectDelivery &&
|
|
493
|
+
(probeVersion(id) !== undefined ||
|
|
494
|
+
(profile.projectDelivery.configPath !== null &&
|
|
495
|
+
existsSync(
|
|
496
|
+
join(options.projectRoot, profile.projectDelivery.configPath.split('/')[0] ?? ''),
|
|
497
|
+
))),
|
|
498
|
+
)
|
|
499
|
+
.map(([id]) => id);
|
|
500
|
+
const outcomes: ProjectHookDeliveryResult[] = [];
|
|
501
|
+
for (const provider of providers) {
|
|
502
|
+
try {
|
|
503
|
+
if (
|
|
504
|
+
!Object.hasOwn(GENERATED_PROVIDER_HOOK_PROFILES, provider) ||
|
|
505
|
+
!/^[a-z][a-z0-9-]*$/.test(provider)
|
|
506
|
+
)
|
|
507
|
+
throw new Error('HOOK_PROVIDER_INVALID');
|
|
508
|
+
await validateStateDirectory(options);
|
|
509
|
+
outcomes.push(
|
|
510
|
+
options.dryRun
|
|
511
|
+
? await deliverProvider(options, provider)
|
|
512
|
+
: await withFileLock(join(options.stateDir, 'provider-' + provider + '.json'), () =>
|
|
513
|
+
deliverProvider(options, provider),
|
|
514
|
+
),
|
|
515
|
+
);
|
|
516
|
+
} catch (error) {
|
|
517
|
+
outcomes.push({
|
|
518
|
+
provider,
|
|
519
|
+
state: 'conflict',
|
|
520
|
+
capability: 'unverified',
|
|
521
|
+
nativeTrust: 'unverified',
|
|
522
|
+
provenance: 'unknown',
|
|
523
|
+
diagnostics: [
|
|
524
|
+
error instanceof Error && /^HOOK_[A-Z_]+$/.test(error.message)
|
|
525
|
+
? error.message
|
|
526
|
+
: 'HOOK_DELIVERY_FAILED',
|
|
527
|
+
],
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return outcomes;
|
|
532
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** Provider payload and response adapters for shared project checks (T13344). */
|
|
2
|
+
import type { HookJsonValue } from '@cleocode/contracts/heavy-command-hook.js';
|
|
3
|
+
import type {
|
|
4
|
+
ProjectHookNativeRequest,
|
|
5
|
+
ProjectHookNativeResult,
|
|
6
|
+
} from '@cleocode/contracts/project-hook-delivery.js';
|
|
7
|
+
import type { HookOutcome, HookRefUpdate } from '@cleocode/contracts/project-hooks.js';
|
|
8
|
+
|
|
9
|
+
function flag(argv: readonly string[], name: string): string | undefined {
|
|
10
|
+
const separator = argv.indexOf('--');
|
|
11
|
+
const options = separator < 0 ? argv : argv.slice(0, separator);
|
|
12
|
+
const index = options.findIndex(
|
|
13
|
+
(arg) => arg === '--' + name || arg.startsWith('--' + name + '='),
|
|
14
|
+
);
|
|
15
|
+
return index < 0
|
|
16
|
+
? undefined
|
|
17
|
+
: options[index]?.split('=').slice(1).join('=') || options[index + 1];
|
|
18
|
+
}
|
|
19
|
+
function object(value: HookJsonValue): value is { readonly [key: string]: HookJsonValue } {
|
|
20
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
function refsFromStdin(body: string): HookRefUpdate[] {
|
|
23
|
+
const refs: HookRefUpdate[] = [];
|
|
24
|
+
for (const line of body.split(/\r?\n/).filter((line) => line.trim())) {
|
|
25
|
+
const fields = line.trim().split(/\s+/);
|
|
26
|
+
const [localRef, localOid, remoteRef, remoteOid] = fields;
|
|
27
|
+
if (
|
|
28
|
+
fields.length !== 4 ||
|
|
29
|
+
!localRef ||
|
|
30
|
+
!remoteRef ||
|
|
31
|
+
!localOid ||
|
|
32
|
+
!remoteOid ||
|
|
33
|
+
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(localOid) ||
|
|
34
|
+
!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(remoteOid)
|
|
35
|
+
)
|
|
36
|
+
throw new Error('HOOK_GIT_INPUT_INVALID');
|
|
37
|
+
refs.push({ localRef, localOid, remoteRef, remoteOid });
|
|
38
|
+
}
|
|
39
|
+
return refs;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Normalize actual Git ref updates or native agent JSON; no Claude environment dependency. */
|
|
43
|
+
export function normalizeNativeProjectHook(
|
|
44
|
+
argv: readonly string[],
|
|
45
|
+
body: string,
|
|
46
|
+
cwd: string,
|
|
47
|
+
): ProjectHookNativeRequest {
|
|
48
|
+
if (Buffer.byteLength(body) > 262144) throw new Error('HOOK_INPUT_TOO_LARGE');
|
|
49
|
+
const source = flag(argv, 'source') ?? 'agent';
|
|
50
|
+
if (source !== 'agent' && source !== 'git') throw new Error('HOOK_SOURCE_INVALID');
|
|
51
|
+
const provider = flag(argv, 'provider') ?? 'claude-code';
|
|
52
|
+
const event = flag(argv, 'event') ?? (source === 'git' ? 'pre-push' : 'PreToolUse');
|
|
53
|
+
const separator = argv.indexOf('--');
|
|
54
|
+
if (source === 'git')
|
|
55
|
+
return {
|
|
56
|
+
cwd,
|
|
57
|
+
provider,
|
|
58
|
+
input: {
|
|
59
|
+
schemaVersion: 1,
|
|
60
|
+
source,
|
|
61
|
+
event,
|
|
62
|
+
refs: refsFromStdin(body),
|
|
63
|
+
remote: {
|
|
64
|
+
name: flag(argv, 'remote-name') ?? (separator < 0 ? '' : argv[separator + 1]) ?? '',
|
|
65
|
+
location:
|
|
66
|
+
flag(argv, 'remote-location') ?? (separator < 0 ? '' : argv[separator + 2]) ?? '',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
const payload = JSON.parse(body || '{}') as HookJsonValue;
|
|
71
|
+
if (!object(payload)) throw new Error('HOOK_AGENT_INPUT_INVALID');
|
|
72
|
+
return {
|
|
73
|
+
cwd: typeof payload.cwd === 'string' ? payload.cwd : cwd,
|
|
74
|
+
provider,
|
|
75
|
+
nativeEvent: typeof payload.hook_event_name === 'string' ? payload.hook_event_name : event,
|
|
76
|
+
input: { schemaVersion: 1, source, event, toolInput: payload.tool_input ?? {} },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Produce advisory-only native output; project blocks apply solely to Git. */
|
|
81
|
+
export function renderNativeProjectHook(
|
|
82
|
+
request: ProjectHookNativeRequest,
|
|
83
|
+
outcomes: HookOutcome[],
|
|
84
|
+
): ProjectHookNativeResult {
|
|
85
|
+
const message = outcomes
|
|
86
|
+
.filter((outcome) => outcome.status !== 'pass' && outcome.status !== 'skip')
|
|
87
|
+
.map(
|
|
88
|
+
(outcome) =>
|
|
89
|
+
'CLEO project hook ' +
|
|
90
|
+
outcome.id +
|
|
91
|
+
': ' +
|
|
92
|
+
outcome.status +
|
|
93
|
+
' (' +
|
|
94
|
+
outcome.code +
|
|
95
|
+
')' +
|
|
96
|
+
(outcome.message ? ': ' + outcome.message : ''),
|
|
97
|
+
)
|
|
98
|
+
.join('\n')
|
|
99
|
+
.slice(0, 8192);
|
|
100
|
+
if (request.input.source === 'agent')
|
|
101
|
+
return {
|
|
102
|
+
exitCode: 0,
|
|
103
|
+
stderr: '',
|
|
104
|
+
stdout: message
|
|
105
|
+
? JSON.stringify(
|
|
106
|
+
request.provider === 'opencode'
|
|
107
|
+
? { context: message }
|
|
108
|
+
: request.input.event === 'PreToolUse'
|
|
109
|
+
? {
|
|
110
|
+
hookSpecificOutput: {
|
|
111
|
+
hookEventName: request.nativeEvent ?? request.input.event,
|
|
112
|
+
additionalContext: message,
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
: { systemMessage: message },
|
|
116
|
+
) + '\n'
|
|
117
|
+
: '',
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
stdout: '',
|
|
121
|
+
stderr: message ? message + '\n' : '',
|
|
122
|
+
exitCode: outcomes.some(
|
|
123
|
+
(outcome) => outcome.blocks && outcome.status !== 'infrastructure-error',
|
|
124
|
+
)
|
|
125
|
+
? 1
|
|
126
|
+
: 0,
|
|
127
|
+
};
|
|
128
|
+
}
|