Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 75 additions & 36 deletions .aiox-core/core/doctor/checks/hooks-claude-count.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,69 @@ const fs = require('fs');

const name = 'hooks-claude-count';

/**
* Extracts every hook command string from a Claude Code settings file.
*
* Hooks may be registered in settings.json (shipped/tracked) or
* settings.local.json (per-machine); Claude Code merges both, so
* registration in either counts.
*
* Returns [] when the file is missing or unparseable.
*/
function collectHookCommands(settingsPath) {
if (!fs.existsSync(settingsPath)) return [];

let settings;
try {
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
} catch {
return [];
}

const hooks = settings.hooks || {};
// Claude Code hooks schema: { EventName: [{ matcher, hooks: [{ type, command }] }] }
const commands = [];
for (const entries of Object.values(hooks)) {
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (entry && Array.isArray(entry.hooks)) {
for (const h of entry.hooks) {
if (h && h.command) commands.push(h.command);
}
}
// Fallback: flat string or direct command
if (typeof entry === 'string') commands.push(entry);
if (entry && typeof entry.command === 'string') commands.push(entry.command);
}
}

return commands;
}

/**
* Extracts referenced hook filenames from command strings.
*
* Matches complete .cjs filenames at path/shell-token boundaries rather than
* by substring: a bare `includes()` would treat `sync.cjs` as referenced by a
* command that only mentions `sync-wrapper.cjs`, turning a missing
* registration into a PASS with an inflated count.
*/
function referencedHookNames(commands) {
const names = new Set();

for (const command of commands) {
for (const rawToken of command.split(/\s+/)) {
// Strip surrounding quotes and trailing shell punctuation
const token = rawToken.replace(/^['"`(]+/, '').replace(/['"`;,)]+$/, '');
if (!token.endsWith('.cjs')) continue;
// Normalize Windows separators before taking the basename
names.add(path.posix.basename(token.replace(/\\/g, '/')));
}
}

return names;
}

async function run(context) {
const hooksDir = path.join(context.projectRoot, '.claude', 'hooks');

Expand Down Expand Up @@ -53,47 +116,23 @@ async function run(context) {
};
}

// Check registration in settings.local.json
const settingsLocalPath = path.join(context.projectRoot, '.claude', 'settings.local.json');
let registered = false;

if (fs.existsSync(settingsLocalPath)) {
try {
const settingsLocal = JSON.parse(fs.readFileSync(settingsLocalPath, 'utf8'));
const hooks = settingsLocal.hooks || {};
// Claude Code hooks schema: { EventName: [{ matcher, hooks: [{ type, command }] }] }
const allHookCommands = [];
for (const entries of Object.values(hooks)) {
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (entry && Array.isArray(entry.hooks)) {
for (const h of entry.hooks) {
if (h && h.command) allHookCommands.push(h.command);
}
}
// Fallback: flat string or direct command
if (typeof entry === 'string') allHookCommands.push(entry);
if (entry && typeof entry.command === 'string') allHookCommands.push(entry.command);
}
}
const hooksStr = allHookCommands.join('\n');
// Check registration in settings.json and settings.local.json (Claude Code merges both).
// Wrapper hooks are registered directly; engine hooks they spawn as child
// processes are not, so any reference is enough to count as wired up.
const claudeDir = path.join(context.projectRoot, '.claude');
const referenced = referencedHookNames([
...collectHookCommands(path.join(claudeDir, 'settings.json')),
...collectHookCommands(path.join(claudeDir, 'settings.local.json')),
]);

// Check if at least some hook files are referenced in settings
const referencedCount = hookFiles.filter(
(f) => hooksStr.includes(f.name) || hooksStr.includes(f.name.replace('.cjs', '')),
).length;

registered = referencedCount > 0;
} catch {
registered = false;
}
}
const referencedCount = hookFiles.filter((f) => referenced.has(f.name)).length;
const registered = referencedCount > 0;

if (hookCount >= 2 && registered) {
return {
check: name,
status: 'PASS',
message: `${hookCount} hook files found and registered`,
message: `${hookCount} hook files found, ${referencedCount} registered`,
fixCommand: null,
};
}
Expand All @@ -102,7 +141,7 @@ async function run(context) {
return {
check: name,
status: 'WARN',
message: `${hookCount} hook files found but not registered in settings.local.json`,
message: `${hookCount} hook files found but not registered in settings.json or settings.local.json`,
fixCommand: 'npx aiox-core install --force',
};
}
Expand Down
46 changes: 46 additions & 0 deletions .aiox-core/core/doctor/checks/settings-json.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,42 @@ const fs = require('fs');

const name = 'settings-json';

/**
* Reads boundary.frameworkProtection from core-config.yaml.
*
* Deny rules exist to stop project consumers from editing L1/L2 framework
* paths. Framework contributors set frameworkProtection: false precisely so
* those paths stay editable, so an empty deny list is correct in that mode.
*
* Defaults to true (protected) when the config or key is absent.
*/
function isFrameworkProtectionEnabled(context) {
const configPath = path.join(context.projectRoot, '.aiox-core', 'core-config.yaml');
if (!fs.existsSync(configPath)) return true;

let content;
try {
content = fs.readFileSync(configPath, 'utf8');
} catch {
return true;
}

let inBoundary = false;
for (const line of content.split('\n')) {
if (/^boundary:\s*$/.test(line)) {
inBoundary = true;
continue;
}
if (!inBoundary) continue;
// A new top-level key ends the boundary section
if (/^\S/.test(line)) break;
const match = line.match(/^\s+frameworkProtection:\s*(true|false)\b/);
if (match) return match[1] === 'true';
}

return true;
}

/**
* Checks that core-config.yaml boundary.protected paths are covered by deny rules.
* Returns array of unprotected boundary paths.
Expand Down Expand Up @@ -90,6 +126,16 @@ async function run(context) {
const denyCount = denyRules.length;
const allowCount = allowRules.length;

// Contributor mode: boundary enforcement is off, so deny rules are not expected
if (!isFrameworkProtectionEnabled(context)) {
return {
check: name,
status: 'PASS',
message: `Deny rules not required (boundary.frameworkProtection: false — contributor mode, ${denyCount} rules, ${allowCount} allows)`,
fixCommand: null,
};
}

if (denyCount < 40) {
return {
check: name,
Expand Down
10 changes: 5 additions & 5 deletions .aiox-core/install-manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# - File types for categorization
#
version: 5.3.0
generated_at: "2026-07-13T20:16:51.869Z"
generated_at: "2026-08-10T20:28:46.967Z"
generator: scripts/generate-install-manifest.js
file_count: 1165
files:
Expand Down Expand Up @@ -337,9 +337,9 @@ files:
type: core
size: 1106
- path: core/doctor/checks/hooks-claude-count.js
hash: sha256:026ddf0248819b89b1147e0876a2934e38e0113d3c6380d68a752d432060e7ec
hash: sha256:fd5d7750e527b22d1e5caf212cecb025a5c62b6d28b0eecbe39e8e875ae8bd16
type: core
size: 3348
size: 4629
- path: core/doctor/checks/ide-sync.js
hash: sha256:4ddd037b4ad18c4201ca1428a1044efd313e9d2721cd399aebd3c5043fd4e2d1
type: core
Expand All @@ -365,9 +365,9 @@ files:
type: core
size: 1368
- path: core/doctor/checks/settings-json.js
hash: sha256:bd26841b966fcfa003eca6f85416d4f877b9dcfea0e4017df9f2a97c14c33fbb
hash: sha256:ac981d682c7232a2fb0e2fe7920741bde45d1a2ce0d3a0bbe33c95b3a4cd8b31
type: core
size: 3286
size: 4726
- path: core/doctor/checks/skills-count.js
hash: sha256:811d904bde6d2ba4940f19cbe6a29cc12c5df6908ac95cb37bcb7add687fe4cc
type: core
Expand Down
Loading