chati-dev 4.2.1 → 4.2.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/framework/config.yaml +3 -3
- package/framework/constitution.md +2 -1
- package/framework/context/root.md +2 -2
- package/framework/hooks/constitution-guard.js +69 -33
- package/framework/hooks/license-guard.js +92 -188
- package/framework/hooks/mode-governance.js +55 -14
- package/framework/hooks/model-governance.js +18 -8
- package/framework/hooks/package.json +3 -0
- package/framework/hooks/prism-engine.js +22 -8
- package/framework/hooks/read-protection.js +37 -9
- package/framework/hooks/session-digest.js +45 -20
- package/framework/hooks/style-guard.js +30 -10
- package/framework/hooks/team-quality-gate.js +39 -13
- package/framework/hooks/undercover-guard.js +30 -11
- package/framework/orchestrator/chati.md +31 -28
- package/package.json +1 -1
- package/scripts/validate-package.js +146 -8
- package/src/config/claude-settings-generator.js +206 -0
- package/src/config/gemini-hooks-generator.js +58 -0
- package/src/installer/core.js +136 -1
- package/src/installer/templates.js +6 -2
- package/src/orchestrator/cli.js +41 -11
|
@@ -309,12 +309,146 @@ function checkEntityCount(packageRoot, results) {
|
|
|
309
309
|
}
|
|
310
310
|
}
|
|
311
311
|
|
|
312
|
+
/**
|
|
313
|
+
* v4.2.2: Validate that generateClaudeSettings() produces a settings.json
|
|
314
|
+
* that wires all 10 chati hooks via the canonical hookSpecificOutput schema
|
|
315
|
+
* — at the right events and with paths that resolve to existing files.
|
|
316
|
+
*
|
|
317
|
+
* This catches the v4.2.0/v4.2.1 catastrophe (hooks dormant because the
|
|
318
|
+
* installer never wrote .claude/settings.json) at validation time.
|
|
319
|
+
*/
|
|
320
|
+
async function checkClaudeHooksWired(packageRoot, results) {
|
|
321
|
+
results.checks++;
|
|
322
|
+
try {
|
|
323
|
+
const genPath = join(packageRoot, 'src', 'config', 'claude-settings-generator.js');
|
|
324
|
+
if (!existsSync(genPath)) {
|
|
325
|
+
results.errors.push('claude-settings-generator.js missing — Claude hooks would not be wired in installs.');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const mod = await import(genPath);
|
|
330
|
+
if (typeof mod.generateClaudeSettings !== 'function') {
|
|
331
|
+
results.errors.push('claude-settings-generator.js does not export generateClaudeSettings.');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const settings = JSON.parse(mod.generateClaudeSettings());
|
|
336
|
+
|
|
337
|
+
// Verify required events
|
|
338
|
+
const requiredEvents = ['UserPromptSubmit', 'PreToolUse', 'PreCompact'];
|
|
339
|
+
const missingEvents = requiredEvents.filter(e => !settings.hooks?.[e]);
|
|
340
|
+
if (missingEvents.length > 0) {
|
|
341
|
+
results.errors.push(`generateClaudeSettings missing events: ${missingEvents.join(', ')}`);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Collect all referenced hook commands
|
|
346
|
+
const allCommands = [];
|
|
347
|
+
for (const event of Object.values(settings.hooks)) {
|
|
348
|
+
for (const group of event) {
|
|
349
|
+
for (const hook of group.hooks || []) {
|
|
350
|
+
allCommands.push(hook.command || '');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Verify all 10 chati hooks are referenced
|
|
356
|
+
const requiredHooks = [
|
|
357
|
+
'license-guard', 'prism-engine', 'model-governance',
|
|
358
|
+
'read-protection', 'constitution-guard', 'mode-governance',
|
|
359
|
+
'style-guard', 'undercover-guard', 'team-quality-gate', 'session-digest',
|
|
360
|
+
];
|
|
361
|
+
const missingHooks = requiredHooks.filter(h => !allCommands.some(c => c.includes(h)));
|
|
362
|
+
if (missingHooks.length > 0) {
|
|
363
|
+
results.errors.push(`generateClaudeSettings missing hook references: ${missingHooks.join(', ')}`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Verify each referenced hook file exists in framework/hooks/
|
|
368
|
+
const hooksDir = join(packageRoot, 'framework', 'hooks');
|
|
369
|
+
if (existsSync(hooksDir)) {
|
|
370
|
+
const missingFiles = [];
|
|
371
|
+
for (const cmd of allCommands) {
|
|
372
|
+
const match = cmd.match(/hooks\/([^\s]+\.js)/);
|
|
373
|
+
if (match && !existsSync(join(hooksDir, match[1]))) {
|
|
374
|
+
missingFiles.push(match[1]);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (missingFiles.length > 0) {
|
|
378
|
+
results.errors.push(`Claude settings reference hook files missing from bundle: ${missingFiles.join(', ')}`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
results.passed++;
|
|
384
|
+
} catch (err) {
|
|
385
|
+
results.errors.push(`checkClaudeHooksWired failed: ${err.message}`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* v4.2.2: Verify license-guard is wired for ALL three providers.
|
|
391
|
+
* Without this, an expired trial keeps working forever — exactly the bug
|
|
392
|
+
* the user flagged in v4.2.1 (they could see chati.dev with no license check).
|
|
393
|
+
*/
|
|
394
|
+
async function checkLicenseEntryPointAcrossProviders(packageRoot, results) {
|
|
395
|
+
results.checks++;
|
|
396
|
+
try {
|
|
397
|
+
const missing = [];
|
|
398
|
+
|
|
399
|
+
// Claude — settings.json must include license-guard in UserPromptSubmit
|
|
400
|
+
const genPath = join(packageRoot, 'src', 'config', 'claude-settings-generator.js');
|
|
401
|
+
if (existsSync(genPath)) {
|
|
402
|
+
const mod = await import(genPath);
|
|
403
|
+
const settings = JSON.parse(mod.generateClaudeSettings());
|
|
404
|
+
const ups = settings.hooks?.UserPromptSubmit || [];
|
|
405
|
+
const claudeCmds = ups.flatMap(g => (g.hooks || []).map(h => h.command || ''));
|
|
406
|
+
if (!claudeCmds.some(c => c.includes('license-guard'))) {
|
|
407
|
+
missing.push('Claude (UserPromptSubmit)');
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
missing.push('Claude (generator file missing)');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Gemini — HOOK_MAP must include license-guard at BeforeModel
|
|
414
|
+
const geminiPath = join(packageRoot, 'src', 'config', 'gemini-hooks-generator.js');
|
|
415
|
+
if (existsSync(geminiPath)) {
|
|
416
|
+
const mod = await import(geminiPath);
|
|
417
|
+
if (!mod.HOOK_MAP?.['license-guard'] || mod.HOOK_MAP['license-guard'].event !== 'BeforeModel') {
|
|
418
|
+
missing.push('Gemini (HOOK_MAP missing license-guard at BeforeModel)');
|
|
419
|
+
}
|
|
420
|
+
} else {
|
|
421
|
+
missing.push('Gemini (generator file missing)');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Codex — installer/core.js must write .codex/hooks.json with license-guard
|
|
425
|
+
const corePath = join(packageRoot, 'src', 'installer', 'core.js');
|
|
426
|
+
if (existsSync(corePath)) {
|
|
427
|
+
const coreContent = readFileSync(corePath, 'utf-8');
|
|
428
|
+
if (!coreContent.includes('.codex/hooks.json') || !coreContent.includes("license-guard")) {
|
|
429
|
+
missing.push('Codex (.codex/hooks.json not wired with license-guard in core.js)');
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
missing.push('Codex (core.js missing)');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (missing.length > 0) {
|
|
436
|
+
results.errors.push(`License enforcement gaps: ${missing.join('; ')}`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
results.passed++;
|
|
441
|
+
} catch (err) {
|
|
442
|
+
results.errors.push(`checkLicenseEntryPointAcrossProviders failed: ${err.message}`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
312
446
|
/**
|
|
313
447
|
* Validate the npm package completeness.
|
|
314
448
|
* @param {string} packageRoot - Root of the package (packages/chati-dev/)
|
|
315
|
-
* @returns {{ errors: string[], warnings: string[], checks: number, passed: number }}
|
|
449
|
+
* @returns {Promise<{ errors: string[], warnings: string[], checks: number, passed: number }>}
|
|
316
450
|
*/
|
|
317
|
-
export function validatePackage(packageRoot) {
|
|
451
|
+
export async function validatePackage(packageRoot) {
|
|
318
452
|
const results = { errors: [], warnings: [], checks: 0, passed: 0 };
|
|
319
453
|
|
|
320
454
|
checkFrameworkExists(packageRoot, results);
|
|
@@ -326,6 +460,8 @@ export function validatePackage(packageRoot) {
|
|
|
326
460
|
checkEntityCount(packageRoot, results);
|
|
327
461
|
checkHooksParity(packageRoot, results);
|
|
328
462
|
checkSourceBundleParity(packageRoot, results);
|
|
463
|
+
await checkClaudeHooksWired(packageRoot, results);
|
|
464
|
+
await checkLicenseEntryPointAcrossProviders(packageRoot, results);
|
|
329
465
|
|
|
330
466
|
return results;
|
|
331
467
|
}
|
|
@@ -357,11 +493,13 @@ function formatResults(results) {
|
|
|
357
493
|
// CLI entry point
|
|
358
494
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
359
495
|
const packageRoot = join(__dirname, '..');
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
496
|
+
validatePackage(packageRoot).then(results => {
|
|
497
|
+
console.log(formatResults(results));
|
|
498
|
+
if (results.errors.length > 0) {
|
|
499
|
+
process.exit(1);
|
|
500
|
+
}
|
|
501
|
+
}).catch(err => {
|
|
502
|
+
console.error('validate-package failed:', err);
|
|
365
503
|
process.exit(1);
|
|
366
|
-
}
|
|
504
|
+
});
|
|
367
505
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code settings.json generator.
|
|
3
|
+
*
|
|
4
|
+
* Produces .claude/settings.json content that wires the 10 chati.dev hooks
|
|
5
|
+
* into Claude Code's hook system and applies a baseline permission policy.
|
|
6
|
+
*
|
|
7
|
+
* Background: prior to v4.2.2, the installer wrote hooks to
|
|
8
|
+
* chati.dev/hooks/settings.json — but Claude Code reads .claude/settings.json,
|
|
9
|
+
* not the framework's bundled file. As a result, every hook was dormant for
|
|
10
|
+
* every Claude Code user since the project started. This generator is the fix:
|
|
11
|
+
* it produces the file Claude Code actually loads, registers all 10 hooks at
|
|
12
|
+
* the canonical events, and applies a generic permission policy modeled on
|
|
13
|
+
* production Claude Code setups.
|
|
14
|
+
*
|
|
15
|
+
* Hook output schema (v4.2.2): all PreToolUse hooks now use the canonical
|
|
16
|
+
* `hookSpecificOutput.permissionDecision` field per Anthropic docs at
|
|
17
|
+
* https://code.claude.com/docs/en/hooks.md. The previous `decision` field
|
|
18
|
+
* was silently no-op'd by Claude Code.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hook registration map. Each entry is one hook script and the events it
|
|
23
|
+
* should fire on. Source of truth lives in chati.dev/hooks/.
|
|
24
|
+
*/
|
|
25
|
+
const HOOK_REGISTRY = [
|
|
26
|
+
// UserPromptSubmit — fires on every user message turn.
|
|
27
|
+
// license-guard MUST be first so license check runs before any other work.
|
|
28
|
+
{ name: 'license-guard', event: 'UserPromptSubmit', matcher: '.*' },
|
|
29
|
+
{ name: 'prism-engine', event: 'UserPromptSubmit', matcher: '.*' },
|
|
30
|
+
{ name: 'model-governance', event: 'UserPromptSubmit', matcher: '.*' },
|
|
31
|
+
|
|
32
|
+
// PreToolUse — fires before each tool call. Matcher targets specific tools.
|
|
33
|
+
{ name: 'read-protection', event: 'PreToolUse', matcher: 'Read' },
|
|
34
|
+
{ name: 'constitution-guard',event: 'PreToolUse', matcher: 'Bash|Write|Edit' },
|
|
35
|
+
{ name: 'mode-governance', event: 'PreToolUse', matcher: 'Write|Edit' },
|
|
36
|
+
{ name: 'style-guard', event: 'PreToolUse', matcher: 'Write|Edit|Bash' },
|
|
37
|
+
{ name: 'undercover-guard', event: 'PreToolUse', matcher: 'Write|Edit|Bash' },
|
|
38
|
+
{ name: 'team-quality-gate', event: 'PreToolUse', matcher: 'Write|Edit' },
|
|
39
|
+
|
|
40
|
+
// PreCompact — fires before context compaction (observational).
|
|
41
|
+
{ name: 'session-digest', event: 'PreCompact', matcher: '' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Baseline permission allow list.
|
|
46
|
+
* Modeled on production Claude Code setups: lets the assistant work freely
|
|
47
|
+
* inside the project without prompting for every tool call. Personal MCP
|
|
48
|
+
* permissions are intentionally NOT included — those are user-specific.
|
|
49
|
+
*/
|
|
50
|
+
const PERMISSIONS_ALLOW = [
|
|
51
|
+
'Read(**/*)',
|
|
52
|
+
'Write(**/*)',
|
|
53
|
+
'Edit(**/*)',
|
|
54
|
+
'Bash',
|
|
55
|
+
'WebFetch',
|
|
56
|
+
'WebSearch',
|
|
57
|
+
'Task',
|
|
58
|
+
'Glob',
|
|
59
|
+
'Grep',
|
|
60
|
+
'NotebookEdit',
|
|
61
|
+
'Skill(*)',
|
|
62
|
+
'TodoWrite',
|
|
63
|
+
'AskUserQuestion',
|
|
64
|
+
'EnterPlanMode',
|
|
65
|
+
'ExitPlanMode',
|
|
66
|
+
'KillShell',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Baseline permission deny list.
|
|
71
|
+
*
|
|
72
|
+
* Categories:
|
|
73
|
+
* - Catastrophic filesystem: deletes that hit root, mass-format, recursive chmod
|
|
74
|
+
* - Git destructive: force push, hard reset, .git removal, hook bypass
|
|
75
|
+
* - System path writes: /etc, /usr, /System, /bin, /sbin (note `//` prefix
|
|
76
|
+
* means absolute filesystem path, not project-relative)
|
|
77
|
+
* - Secret reads: SSH keys, AWS/GCP credentials, npm tokens, GitHub tokens
|
|
78
|
+
* - Accidental publish: npm/yarn/pnpm/pip/cargo publish commands
|
|
79
|
+
*
|
|
80
|
+
* Path syntax (from Claude Code docs):
|
|
81
|
+
* - `Bash(cmd:*)` matches "cmd" with any args
|
|
82
|
+
* - `~/path/**` expands ~ to home dir, ** matches recursive
|
|
83
|
+
* - `//etc/**` double-slash means absolute filesystem path
|
|
84
|
+
* - `/path/**` single-slash is project-root-relative (NOT absolute)
|
|
85
|
+
*/
|
|
86
|
+
const PERMISSIONS_DENY = [
|
|
87
|
+
// Catastrophic filesystem
|
|
88
|
+
'Bash(rm -rf /)',
|
|
89
|
+
'Bash(rm -rf /*)',
|
|
90
|
+
'Bash(rm -rf ~)',
|
|
91
|
+
'Bash(rm -rf ~/*)',
|
|
92
|
+
'Bash(rm -rf $HOME)',
|
|
93
|
+
'Bash(rm -rf $HOME/*)',
|
|
94
|
+
'Bash(sudo rm -rf:*)',
|
|
95
|
+
'Bash(mkfs:*)',
|
|
96
|
+
'Bash(dd if=/dev/zero:*)',
|
|
97
|
+
'Bash(dd if=/dev/random:*)',
|
|
98
|
+
'Bash(chmod -R 777 /)',
|
|
99
|
+
'Bash(chown -R:* /)',
|
|
100
|
+
|
|
101
|
+
// Git destructive
|
|
102
|
+
'Bash(git push --force:*)',
|
|
103
|
+
'Bash(git push -f:*)',
|
|
104
|
+
'Bash(git push --force-with-lease:*)',
|
|
105
|
+
'Bash(git reset --hard:*)',
|
|
106
|
+
'Bash(git clean -fd:*)',
|
|
107
|
+
'Bash(git clean -fdx:*)',
|
|
108
|
+
'Bash(git clean -fx:*)',
|
|
109
|
+
'Bash(rm -rf .git)',
|
|
110
|
+
'Bash(rm -rf .git/*)',
|
|
111
|
+
|
|
112
|
+
// Bypass hooks (always suspect)
|
|
113
|
+
'Bash(git commit --no-verify:*)',
|
|
114
|
+
'Bash(git commit -n:*)',
|
|
115
|
+
'Bash(git commit --no-gpg-sign:*)',
|
|
116
|
+
'Bash(git rebase --no-verify:*)',
|
|
117
|
+
|
|
118
|
+
// System path writes — `//` prefix = absolute filesystem
|
|
119
|
+
'Write(//etc/**)',
|
|
120
|
+
'Write(//usr/**)',
|
|
121
|
+
'Write(//System/**)',
|
|
122
|
+
'Write(//bin/**)',
|
|
123
|
+
'Write(//sbin/**)',
|
|
124
|
+
'Write(//Library/**)',
|
|
125
|
+
'Edit(//etc/**)',
|
|
126
|
+
'Edit(//usr/**)',
|
|
127
|
+
'Edit(//System/**)',
|
|
128
|
+
'Edit(//bin/**)',
|
|
129
|
+
'Edit(//sbin/**)',
|
|
130
|
+
'Edit(//Library/**)',
|
|
131
|
+
|
|
132
|
+
// Credentials and secrets
|
|
133
|
+
'Read(~/.ssh/**)',
|
|
134
|
+
'Read(~/.aws/credentials)',
|
|
135
|
+
'Read(~/.aws/config)',
|
|
136
|
+
'Read(~/.gnupg/**)',
|
|
137
|
+
'Read(~/.config/gh/hosts.yml)',
|
|
138
|
+
'Read(~/.docker/config.json)',
|
|
139
|
+
'Read(~/.npmrc)',
|
|
140
|
+
'Read(~/.pypirc)',
|
|
141
|
+
'Read(~/.netrc)',
|
|
142
|
+
|
|
143
|
+
// Accidental publish
|
|
144
|
+
'Bash(npm publish:*)',
|
|
145
|
+
'Bash(yarn publish:*)',
|
|
146
|
+
'Bash(pnpm publish:*)',
|
|
147
|
+
'Bash(pip upload:*)',
|
|
148
|
+
'Bash(twine upload:*)',
|
|
149
|
+
'Bash(cargo publish:*)',
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Group HOOK_REGISTRY entries by event, then by matcher within each event.
|
|
154
|
+
* Returns the shape Claude Code expects under the top-level "hooks" key.
|
|
155
|
+
*/
|
|
156
|
+
function buildHooksObject() {
|
|
157
|
+
const events = {};
|
|
158
|
+
|
|
159
|
+
for (const entry of HOOK_REGISTRY) {
|
|
160
|
+
if (!events[entry.event]) events[entry.event] = new Map();
|
|
161
|
+
const matcherGroups = events[entry.event];
|
|
162
|
+
if (!matcherGroups.has(entry.matcher)) {
|
|
163
|
+
matcherGroups.set(entry.matcher, []);
|
|
164
|
+
}
|
|
165
|
+
matcherGroups.get(entry.matcher).push({
|
|
166
|
+
type: 'command',
|
|
167
|
+
command: `node chati.dev/hooks/${entry.name}.js`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const result = {};
|
|
172
|
+
for (const [event, matcherGroups] of Object.entries(events)) {
|
|
173
|
+
result[event] = [];
|
|
174
|
+
for (const [matcher, hooks] of matcherGroups.entries()) {
|
|
175
|
+
const group = { hooks };
|
|
176
|
+
if (matcher) group.matcher = matcher;
|
|
177
|
+
result[event].push(group);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Generate the full settings.json content.
|
|
185
|
+
*
|
|
186
|
+
* @returns {string} JSON string with trailing newline, ready to write to disk.
|
|
187
|
+
*/
|
|
188
|
+
export function generateClaudeSettings() {
|
|
189
|
+
const settings = {
|
|
190
|
+
$schema: 'https://json.schemastore.org/claude-code-settings.json',
|
|
191
|
+
permissions: {
|
|
192
|
+
allow: PERMISSIONS_ALLOW,
|
|
193
|
+
deny: PERMISSIONS_DENY,
|
|
194
|
+
defaultMode: 'default',
|
|
195
|
+
},
|
|
196
|
+
hooks: buildHooksObject(),
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return JSON.stringify(settings, null, 2) + '\n';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export {
|
|
203
|
+
HOOK_REGISTRY,
|
|
204
|
+
PERMISSIONS_ALLOW,
|
|
205
|
+
PERMISSIONS_DENY,
|
|
206
|
+
};
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
* Maps each chati.dev hook to its Gemini CLI equivalent event.
|
|
23
23
|
*/
|
|
24
24
|
export const HOOK_MAP = {
|
|
25
|
+
// license-guard MUST be first on BeforeModel so license check runs before
|
|
26
|
+
// any model call. Without this, an expired trial could keep using Gemini
|
|
27
|
+
// indefinitely from a long-running session.
|
|
28
|
+
'license-guard': { event: 'BeforeModel', description: 'Validate license on every turn (chati.dev governance)' },
|
|
25
29
|
'prism-engine': { event: 'BeforeModel', description: 'Inject PRISM context into model prompt' },
|
|
26
30
|
'model-governance': { event: 'BeforeModel', description: 'Advisory: recommended model per agent' },
|
|
27
31
|
'mode-governance': { event: 'BeforeTool', description: 'Block writes outside current mode scope' },
|
|
@@ -611,6 +615,7 @@ main();
|
|
|
611
615
|
*/
|
|
612
616
|
export function generateAllGeminiHooks() {
|
|
613
617
|
return {
|
|
618
|
+
'license-guard.js': generateLicenseGuard(),
|
|
614
619
|
'prism-engine.js': generatePrismEngine(),
|
|
615
620
|
'model-governance.js': generateModelGovernance(),
|
|
616
621
|
'mode-governance.js': generateModeGovernance(),
|
|
@@ -622,6 +627,59 @@ export function generateAllGeminiHooks() {
|
|
|
622
627
|
};
|
|
623
628
|
}
|
|
624
629
|
|
|
630
|
+
/**
|
|
631
|
+
* Generate the license guard hook for Gemini CLI.
|
|
632
|
+
* BeforeModel event — validates license on every turn.
|
|
633
|
+
*
|
|
634
|
+
* Delegates to chati.dev/hooks/license-guard.js (canonical implementation)
|
|
635
|
+
* via dynamic import of checkLicense(). Translates the verdict object to
|
|
636
|
+
* Gemini's blocking schema (exit code 2 + stderr is the universal block).
|
|
637
|
+
*/
|
|
638
|
+
function generateLicenseGuard() {
|
|
639
|
+
return `${HOOK_HEADER}
|
|
640
|
+
async function main() {
|
|
641
|
+
let input = '';
|
|
642
|
+
for await (const chunk of process.stdin) {
|
|
643
|
+
input += chunk;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
const event = JSON.parse(input);
|
|
648
|
+
const cwd = event.cwd || process.cwd();
|
|
649
|
+
|
|
650
|
+
// Delegate to canonical license check
|
|
651
|
+
const hookPath = join(cwd, 'chati.dev', 'hooks', 'license-guard.js');
|
|
652
|
+
if (!existsSync(hookPath)) {
|
|
653
|
+
// No canonical hook present — fail open
|
|
654
|
+
console.log(JSON.stringify({}));
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const mod = await import(hookPath);
|
|
659
|
+
if (typeof mod.checkLicense !== 'function') {
|
|
660
|
+
console.log(JSON.stringify({}));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const result = await mod.checkLicense();
|
|
665
|
+
if (result.valid) {
|
|
666
|
+
console.log(JSON.stringify({}));
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Block via exit code 2 (universal Gemini block) + stderr message.
|
|
671
|
+
process.stderr.write(result.reason || 'License invalid');
|
|
672
|
+
process.exit(2);
|
|
673
|
+
} catch {
|
|
674
|
+
// Fail open on any parse/import error — never block on bugs.
|
|
675
|
+
console.log(JSON.stringify({}));
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
main();
|
|
680
|
+
`;
|
|
681
|
+
}
|
|
682
|
+
|
|
625
683
|
/**
|
|
626
684
|
* Generate .gemini/settings.json content with hook configuration.
|
|
627
685
|
*
|
package/src/installer/core.js
CHANGED
|
@@ -6,6 +6,7 @@ import { generateClaudeMCPConfig } from '../config/mcp-configs.js';
|
|
|
6
6
|
import { generateSessionYaml, generateConfigYaml, generateClaudeMd, generateClaudeLocalMd, generateCodexSkill, generateGeminiRouter, generateGeminiSessionLock, generateAgentsOverrideMd, generateCodexConstitutionGuardRules, generateCodexReadProtectionRules } from './templates.js';
|
|
7
7
|
import { generateContextFiles } from '../config/context-file-generator.js';
|
|
8
8
|
import { adaptFrameworkFile, ADAPTABLE_FILES } from '../config/framework-adapter.js';
|
|
9
|
+
import { generateClaudeSettings } from '../config/claude-settings-generator.js';
|
|
9
10
|
import { generateProviderOverlays } from './provider-overlay.js';
|
|
10
11
|
import { verifyManifest } from './manifest.js';
|
|
11
12
|
|
|
@@ -290,6 +291,12 @@ Pass through all context: session state, handoffs, artifacts, and user input.
|
|
|
290
291
|
`;
|
|
291
292
|
writeFileSync(join(targetDir, '.claude', 'commands', 'chati.md'), routerContent, 'utf-8');
|
|
292
293
|
|
|
294
|
+
// .claude/settings.json — wires the 10 chati.dev hooks into Claude Code's
|
|
295
|
+
// hook system and applies a baseline permission policy. THIS IS THE FILE
|
|
296
|
+
// CLAUDE CODE READS — without it, every hook in chati.dev/hooks/ is dormant.
|
|
297
|
+
// (Pre-v4.2.2 bug: this file was never written, hooks never ran.)
|
|
298
|
+
writeClaudeSettingsWithMerge(join(targetDir, '.claude', 'settings.json'));
|
|
299
|
+
|
|
293
300
|
// MCP config
|
|
294
301
|
if (selectedMCPs.length > 0) {
|
|
295
302
|
const mcpConfig = generateClaudeMCPConfig(selectedMCPs);
|
|
@@ -307,10 +314,35 @@ Pass through all context: session state, handoffs, artifacts, and user input.
|
|
|
307
314
|
// Session lock override file (equivalent to CLAUDE.local.md)
|
|
308
315
|
writeFileSync(join(targetDir, 'AGENTS.override.md'), generateAgentsOverrideMd(), 'utf-8');
|
|
309
316
|
|
|
310
|
-
// Starlark execution policies (
|
|
317
|
+
// Starlark execution policies (Codex sandboxed rule engine)
|
|
311
318
|
createDir(join(targetDir, '.codex', 'rules'));
|
|
312
319
|
writeFileSync(join(targetDir, '.codex', 'rules', 'constitution-guard.rules'), generateCodexConstitutionGuardRules(), 'utf-8');
|
|
313
320
|
writeFileSync(join(targetDir, '.codex', 'rules', 'read-protection.rules'), generateCodexReadProtectionRules(), 'utf-8');
|
|
321
|
+
|
|
322
|
+
// .codex/hooks.json — license-guard wired to UserPromptSubmit (per-turn enforcement).
|
|
323
|
+
// Codex hooks.json is experimental (requires [features] codex_hooks = true in
|
|
324
|
+
// codex config). Without it, license enforcement only happens at slash command
|
|
325
|
+
// entry — a long-running terminal session would not be re-validated.
|
|
326
|
+
writeFileSync(
|
|
327
|
+
join(targetDir, '.codex', 'hooks.json'),
|
|
328
|
+
JSON.stringify({
|
|
329
|
+
$comment: 'chati.dev v4.2.2 — experimental Codex hooks. Requires [features] codex_hooks = true.',
|
|
330
|
+
hooks: {
|
|
331
|
+
UserPromptSubmit: [
|
|
332
|
+
{
|
|
333
|
+
matcher: '.*',
|
|
334
|
+
hooks: [
|
|
335
|
+
{
|
|
336
|
+
type: 'command',
|
|
337
|
+
command: 'node chati.dev/hooks/license-guard.js',
|
|
338
|
+
},
|
|
339
|
+
],
|
|
340
|
+
},
|
|
341
|
+
],
|
|
342
|
+
},
|
|
343
|
+
}, null, 2) + '\n',
|
|
344
|
+
'utf-8'
|
|
345
|
+
);
|
|
314
346
|
} else if (ideKey === 'gemini-cli') {
|
|
315
347
|
// Gemini CLI: TOML command file (native format for /chati command)
|
|
316
348
|
writeFileSync(join(targetDir, '.gemini', 'commands', 'chati.toml'), generateGeminiRouter({ orchestratorPath }), 'utf-8');
|
|
@@ -430,3 +462,106 @@ function createDir(dir) {
|
|
|
430
462
|
mkdirSync(dir, { recursive: true });
|
|
431
463
|
}
|
|
432
464
|
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Write .claude/settings.json with safe merge against existing user content.
|
|
468
|
+
*
|
|
469
|
+
* If the file does not exist: write fresh chati settings.
|
|
470
|
+
* If the file exists and is valid JSON: merge — chati hooks/permissions are
|
|
471
|
+
* UNIONED into existing values rather than replacing them. User customizations
|
|
472
|
+
* (env, statusLine, plugins, language, alwaysThinkingEnabled, etc.) are preserved.
|
|
473
|
+
* If the file exists but is malformed: leave it alone, log a warning. We will
|
|
474
|
+
* not silently destroy user data.
|
|
475
|
+
*/
|
|
476
|
+
function writeClaudeSettingsWithMerge(settingsPath) {
|
|
477
|
+
createDir(dirname(settingsPath));
|
|
478
|
+
const fresh = JSON.parse(generateClaudeSettings());
|
|
479
|
+
|
|
480
|
+
if (!existsSync(settingsPath)) {
|
|
481
|
+
writeFileSync(settingsPath, JSON.stringify(fresh, null, 2) + '\n', 'utf-8');
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let existing;
|
|
486
|
+
try {
|
|
487
|
+
existing = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
488
|
+
} catch (err) {
|
|
489
|
+
process.stderr.write(`[chati] WARNING: .claude/settings.json exists but is not valid JSON (${err.message}). Skipping merge — chati hooks will NOT be wired. Fix the file and re-run install.\n`);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const merged = mergeClaudeSettings(existing, fresh);
|
|
494
|
+
writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Merge two Claude Code settings.json objects.
|
|
499
|
+
*
|
|
500
|
+
* - permissions.allow / permissions.deny: array union (de-duplicated)
|
|
501
|
+
* - permissions.defaultMode: keep existing if set, else use fresh
|
|
502
|
+
* - hooks: per-event union — chati hook commands are appended to existing
|
|
503
|
+
* matcher groups; user's hooks for the same events are preserved
|
|
504
|
+
* - $schema: prefer fresh (always our canonical URL)
|
|
505
|
+
* - all other top-level keys (env, statusLine, plugins, language, etc.):
|
|
506
|
+
* keep existing untouched
|
|
507
|
+
*/
|
|
508
|
+
export function mergeClaudeSettings(existing, fresh) {
|
|
509
|
+
const merged = { ...existing };
|
|
510
|
+
|
|
511
|
+
// $schema — chati's canonical URL wins
|
|
512
|
+
if (fresh.$schema) merged.$schema = fresh.$schema;
|
|
513
|
+
|
|
514
|
+
// permissions
|
|
515
|
+
const existingPerms = existing.permissions || {};
|
|
516
|
+
const freshPerms = fresh.permissions || {};
|
|
517
|
+
merged.permissions = {
|
|
518
|
+
...existingPerms,
|
|
519
|
+
allow: unionArrays(existingPerms.allow, freshPerms.allow),
|
|
520
|
+
deny: unionArrays(existingPerms.deny, freshPerms.deny),
|
|
521
|
+
defaultMode: existingPerms.defaultMode || freshPerms.defaultMode || 'default',
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// hooks — per-event merge
|
|
525
|
+
const existingHooks = existing.hooks || {};
|
|
526
|
+
const freshHooks = fresh.hooks || {};
|
|
527
|
+
merged.hooks = { ...existingHooks };
|
|
528
|
+
|
|
529
|
+
for (const [eventName, freshGroups] of Object.entries(freshHooks)) {
|
|
530
|
+
const existingGroups = existingHooks[eventName] || [];
|
|
531
|
+
// Append fresh groups; we don't dedup at the group level (different
|
|
532
|
+
// matchers are different groups). Within a single fresh group, the
|
|
533
|
+
// hook commands are unique to chati so duplication risk is minimal.
|
|
534
|
+
// To be safe, dedup by command string across the merged event.
|
|
535
|
+
const seen = new Set();
|
|
536
|
+
const collected = [];
|
|
537
|
+
for (const group of [...existingGroups, ...freshGroups]) {
|
|
538
|
+
const dedupedHooks = (group.hooks || []).filter(h => {
|
|
539
|
+
const key = `${group.matcher || ''}|${h.command}`;
|
|
540
|
+
if (seen.has(key)) return false;
|
|
541
|
+
seen.add(key);
|
|
542
|
+
return true;
|
|
543
|
+
});
|
|
544
|
+
if (dedupedHooks.length > 0) {
|
|
545
|
+
collected.push({
|
|
546
|
+
...(group.matcher !== undefined ? { matcher: group.matcher } : {}),
|
|
547
|
+
hooks: dedupedHooks,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
merged.hooks[eventName] = collected;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return merged;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function unionArrays(a, b) {
|
|
558
|
+
const out = [];
|
|
559
|
+
const seen = new Set();
|
|
560
|
+
for (const item of [...(a || []), ...(b || [])]) {
|
|
561
|
+
if (!seen.has(item)) {
|
|
562
|
+
seen.add(item);
|
|
563
|
+
out.push(item);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
@@ -134,8 +134,12 @@ export function generateConfigYaml(config) {
|
|
|
134
134
|
model_fallback: true,
|
|
135
135
|
frustration_detection: true,
|
|
136
136
|
bash_security_checks: true,
|
|
137
|
-
// Agent Teams (v4.2.
|
|
138
|
-
|
|
137
|
+
// Agent Teams (Article XXI) — default ON in v4.2.2.
|
|
138
|
+
// Only effective when provider === 'claude' (gated in cli.js
|
|
139
|
+
// isAgentTeamsEnabled). Gemini and Codex always fall back to sequential
|
|
140
|
+
// pipeline silently. See plan: was previously false for "safe rollout"
|
|
141
|
+
// but the v4.2.0 launch never actually shipped, so this is a fresh start.
|
|
142
|
+
agent_teams: true,
|
|
139
143
|
team_planning_size: 3,
|
|
140
144
|
team_build_size: 2,
|
|
141
145
|
team_echo_threshold: 0.92,
|