mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-18 22:13:45 +02:00
181 lines
6.8 KiB
JavaScript
181 lines
6.8 KiB
JavaScript
/**
|
|
* Trust-boundary tests for the ECC-native Hookify runtime (#2561).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const assert = require('assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
const runtime = require('../../scripts/hooks/hookify-runtime');
|
|
|
|
function test(name, fn) {
|
|
try {
|
|
fn();
|
|
console.log(' \u2713 ' + name);
|
|
return true;
|
|
} catch (error) {
|
|
console.log(' \u2717 ' + name);
|
|
console.log(' Error: ' + error.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function createProject(prefix = 'ecc-hookify-security-') {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
fs.mkdirSync(path.join(root, '.claude'), { recursive: true });
|
|
return root;
|
|
}
|
|
|
|
function writeRule(root, slug, frontmatter, message = 'Hookify policy matched.') {
|
|
const file = path.join(root, '.claude', 'hookify.' + slug + '.local.md');
|
|
fs.writeFileSync(file, '---\n' + frontmatter.trim() + '\n---\n' + message + '\n');
|
|
return file;
|
|
}
|
|
|
|
function runHook(root, input, env = {}) {
|
|
const raw = JSON.stringify(input);
|
|
return runtime.run(raw, {
|
|
cwd: root,
|
|
env: { ...process.env, CLAUDE_PROJECT_DIR: root, ...env },
|
|
});
|
|
}
|
|
|
|
function removeProject(root) {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
|
|
console.log('\nHookify security tests (#2561)');
|
|
console.log('\u2500'.repeat(50));
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
if (test('does not follow rule symlinks outside the project .claude directory', () => {
|
|
if (process.platform === 'win32') return;
|
|
const root = createProject();
|
|
const outside = createProject('ecc-hookify-outside-');
|
|
try {
|
|
const externalRule = path.join(outside, 'external.md');
|
|
fs.writeFileSync(externalRule, '---\nname: outside\nevent: bash\naction: block\npattern: .*\n---\nMust not load.\n');
|
|
fs.symlinkSync(externalRule, path.join(root, '.claude', 'hookify.link.local.md'));
|
|
const result = runHook(root, { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'pwd' } });
|
|
assert.strictEqual(result.stdout, result.raw);
|
|
assert.match(result.stderr, /symbolic link/);
|
|
} finally {
|
|
removeProject(root);
|
|
removeProject(outside);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('rejects Git-tracked local rules unless explicitly approved', () => {
|
|
const root = createProject();
|
|
try {
|
|
assert.strictEqual(spawnSync('git', ['init', '-q'], { cwd: root }).status, 0);
|
|
const rulePath = writeRule(root, 'tracked', 'name: tracked-rule\nevent: bash\naction: block\npattern: .*', 'Tracked instructions must not run by default.');
|
|
assert.strictEqual(spawnSync('git', ['add', '-f', path.relative(root, rulePath)], { cwd: root }).status, 0);
|
|
const input = { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'pwd' } };
|
|
const rejected = runHook(root, input);
|
|
assert.strictEqual(rejected.stdout, rejected.raw);
|
|
assert.match(rejected.stderr, /tracked local rules require explicit approval/);
|
|
const approved = JSON.parse(runHook(root, input, { ECC_HOOKIFY_ALLOW_TRACKED: '1' }).stdout);
|
|
assert.strictEqual(approved.hookSpecificOutput.permissionDecision, 'deny');
|
|
assert.match(approved.hookSpecificOutput.permissionDecisionReason, /UNTRUSTED LOCAL RULE DATA/);
|
|
} finally {
|
|
removeProject(root);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('detects tracked rules through case-variant .claude paths', () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-hookify-case-trust-'));
|
|
try {
|
|
assert.strictEqual(spawnSync('git', ['init', '-q'], { cwd: root }).status, 0);
|
|
const trackedDir = path.join(root, '.CLAUDE');
|
|
fs.mkdirSync(trackedDir);
|
|
fs.writeFileSync(
|
|
path.join(trackedDir, 'hookify.case.local.md'),
|
|
'---\nname: case-rule\nevent: bash\naction: block\npattern: .*\n---\nTracked data.\n'
|
|
);
|
|
assert.strictEqual(
|
|
spawnSync('git', ['add', '-f', '.CLAUDE/hookify.case.local.md'], { cwd: root }).status,
|
|
0
|
|
);
|
|
|
|
const tracked = runtime.listTrackedRuleFiles(root);
|
|
assert.strictEqual(tracked.error, '');
|
|
assert.ok(tracked.files.has('.claude/hookify.case.local.md'));
|
|
} finally {
|
|
removeProject(root);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('tracked rules cannot exhaust the rule limit before local rules are considered', () => {
|
|
const root = createProject();
|
|
try {
|
|
assert.strictEqual(spawnSync('git', ['init', '-q'], { cwd: root }).status, 0);
|
|
const trackedPaths = [];
|
|
for (let index = 0; index < runtime.MAX_RULES; index += 1) {
|
|
trackedPaths.push(path.relative(
|
|
root,
|
|
writeRule(
|
|
root,
|
|
'aaa-' + String(index).padStart(3, '0'),
|
|
'name: tracked-' + index + '\nevent: bash\naction: warn\npattern: NEVER_MATCH'
|
|
)
|
|
));
|
|
}
|
|
assert.strictEqual(spawnSync('git', ['add', '-f', ...trackedPaths], { cwd: root }).status, 0);
|
|
writeRule(
|
|
root,
|
|
'zzz-local',
|
|
'name: local-block\nevent: bash\naction: block\npattern: BLOCK_ME',
|
|
'The local blocking rule must run.'
|
|
);
|
|
|
|
const result = runHook(root, {
|
|
hook_event_name: 'PreToolUse',
|
|
tool_name: 'Bash',
|
|
tool_input: { command: 'echo BLOCK_ME' },
|
|
});
|
|
const output = JSON.parse(result.stdout);
|
|
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
|
assert.match(output.hookSpecificOutput.permissionDecisionReason, /local-block/);
|
|
} finally {
|
|
removeProject(root);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('fails closed when Git tracking status cannot be established', () => {
|
|
const root = createProject();
|
|
try {
|
|
assert.strictEqual(spawnSync('git', ['init', '-q'], { cwd: root }).status, 0);
|
|
writeRule(root, 'unknown-trust', 'name: unknown-trust\nevent: bash\naction: block\npattern: .*');
|
|
const result = runHook(root, { hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'pwd' } }, { PATH: '' });
|
|
assert.strictEqual(result.stdout, result.raw);
|
|
assert.match(result.stderr, /could not verify whether local rules are tracked/);
|
|
} finally {
|
|
removeProject(root);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
if (test('trusted CLAUDE_PROJECT_DIR takes precedence over payload cwd', () => {
|
|
const trusted = createProject();
|
|
const untrusted = createProject();
|
|
try {
|
|
writeRule(untrusted, 'outside', 'name: outside-rule\nevent: bash\naction: block\npattern: .*', 'Must not load.');
|
|
const raw = JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'pwd' }, cwd: untrusted });
|
|
const result = runtime.run(raw, { cwd: trusted, env: { ...process.env, CLAUDE_PROJECT_DIR: trusted } });
|
|
assert.strictEqual(result.stdout, result.raw);
|
|
} finally {
|
|
removeProject(trusted);
|
|
removeProject(untrusted);
|
|
}
|
|
})) passed++; else failed++;
|
|
|
|
console.log('\nPassed: ' + passed);
|
|
console.log('Failed: ' + failed);
|
|
process.exit(failed > 0 ? 1 : 0);
|