mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-11 02:33:10 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2707ef2eb2 |
@@ -7,13 +7,12 @@
|
|||||||
* the actual code. This hook steers the agent back to fixing the source.
|
* the actual code. This hook steers the agent back to fixing the source.
|
||||||
*
|
*
|
||||||
* Exit codes:
|
* Exit codes:
|
||||||
* 0 = allow (not a config file, or first-time creation of one)
|
* 0 = allow (not a config file)
|
||||||
* 2 = block (existing config file modification attempted)
|
* 2 = block (config file modification attempted)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const MAX_STDIN = 1024 * 1024;
|
const MAX_STDIN = 1024 * 1024;
|
||||||
@@ -59,7 +58,7 @@ const PROTECTED_FILES = new Set([
|
|||||||
'.stylelintrc.yml',
|
'.stylelintrc.yml',
|
||||||
'.markdownlint.json',
|
'.markdownlint.json',
|
||||||
'.markdownlint.yaml',
|
'.markdownlint.yaml',
|
||||||
'.markdownlintrc'
|
'.markdownlintrc',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function parseInput(inputOrRaw) {
|
function parseInput(inputOrRaw) {
|
||||||
@@ -95,41 +94,13 @@ function run(inputOrRaw, options = {}) {
|
|||||||
|
|
||||||
const basename = path.basename(filePath);
|
const basename = path.basename(filePath);
|
||||||
if (PROTECTED_FILES.has(basename)) {
|
if (PROTECTED_FILES.has(basename)) {
|
||||||
// Allow first-time creation — there's no existing config to weaken.
|
|
||||||
// The hook's purpose is blocking modifications; writing a brand-new
|
|
||||||
// config file in a project that has none is a legitimate bootstrap
|
|
||||||
// path (e.g. scaffolding ESLint into a fresh repo).
|
|
||||||
//
|
|
||||||
// Fail closed on any stat error other than ENOENT. Use lstatSync so a
|
|
||||||
// symlink at the protected path is treated as present even if its target
|
|
||||||
// is missing — a dangling symlink at e.g. .eslintrc.js still represents
|
|
||||||
// an existing config entry that an agent should not silently replace.
|
|
||||||
// fs.existsSync would swallow EACCES/EPERM as false; lstatSync exposes
|
|
||||||
// the error code so we can treat only genuine "path not found" (ENOENT)
|
|
||||||
// as absent.
|
|
||||||
let exists = true;
|
|
||||||
try {
|
|
||||||
fs.lstatSync(filePath);
|
|
||||||
// lstat succeeded — something (file, dir, or symlink) exists here.
|
|
||||||
} catch (err) {
|
|
||||||
if (err && err.code === 'ENOENT') {
|
|
||||||
exists = false;
|
|
||||||
}
|
|
||||||
// Any other error (EACCES, EPERM, ELOOP, etc.) leaves exists=true
|
|
||||||
// so the guard is never silently weakened.
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!exists) {
|
|
||||||
return { exitCode: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
exitCode: 2,
|
exitCode: 2,
|
||||||
stderr:
|
stderr:
|
||||||
`BLOCKED: Modifying ${basename} is not allowed. ` +
|
`BLOCKED: Modifying ${basename} is not allowed. ` +
|
||||||
'Fix the source code to satisfy linter/formatter rules instead of ' +
|
'Fix the source code to satisfy linter/formatter rules instead of ' +
|
||||||
'weakening the config. If this is a legitimate config change, ' +
|
'weakening the config. If this is a legitimate config change, ' +
|
||||||
'disable the config-protection hook temporarily.'
|
'disable the config-protection hook temporarily.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +125,7 @@ process.stdin.on('data', chunk => {
|
|||||||
process.stdin.on('end', () => {
|
process.stdin.on('end', () => {
|
||||||
const result = run(raw, {
|
const result = run(raw, {
|
||||||
truncated,
|
truncated,
|
||||||
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN
|
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.stderr) {
|
if (result.stderr) {
|
||||||
|
|||||||
@@ -1,63 +1,157 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Cost Tracker Hook
|
* Cost Tracker Hook (v2)
|
||||||
*
|
*
|
||||||
* Appends lightweight session usage metrics to ~/.claude/metrics/costs.jsonl.
|
* Reads transcript_path from Stop hook stdin, sums usage across all
|
||||||
|
* assistant turns in the session JSONL, and appends one row to
|
||||||
|
* ~/.claude/metrics/costs.jsonl.
|
||||||
|
*
|
||||||
|
* Stop hook stdin payload: { session_id, transcript_path, cwd, hook_event_name, ... }
|
||||||
|
* The Stop payload does NOT include `usage` or `model` directly. The previous
|
||||||
|
* version of this hook expected those fields and silently produced zero-filled
|
||||||
|
* rows (verified: 2,340 rows captured with 0.0% non-zero token rate over 52
|
||||||
|
* days). The fix is to read the transcript file Claude Code already passes us.
|
||||||
|
*
|
||||||
|
* JSONL assistant entry shape (per Claude Code):
|
||||||
|
* { type: "assistant", message: { model, usage: { input_tokens, output_tokens,
|
||||||
|
* cache_creation_input_tokens, cache_read_input_tokens } } }
|
||||||
|
*
|
||||||
|
* Cumulative behavior: Stop fires per assistant response, not per session.
|
||||||
|
* Each row therefore represents the cumulative session total up to that point.
|
||||||
|
* To get per-session cost, take the last row per session_id. To get per-day
|
||||||
|
* spend, aggregate.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils');
|
const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils');
|
||||||
const { estimateCost } = require('../lib/cost-estimate');
|
|
||||||
const { sanitizeSessionId } = require('../lib/session-bridge');
|
const { sanitizeSessionId } = require('../lib/session-bridge');
|
||||||
|
|
||||||
const MAX_STDIN = 1024 * 1024;
|
// Approximate per-1M-token billing rates (USD).
|
||||||
let raw = '';
|
// Cache creation: 1.25x input rate. Cache read: 0.1x input rate.
|
||||||
|
const RATE_TABLE = {
|
||||||
|
haiku: { in: 0.80, out: 4.0, cacheWrite: 1.00, cacheRead: 0.08 },
|
||||||
|
sonnet: { in: 3.00, out: 15.0, cacheWrite: 3.75, cacheRead: 0.30 },
|
||||||
|
opus: { in: 15.00, out: 75.0, cacheWrite: 18.75, cacheRead: 1.50 }
|
||||||
|
};
|
||||||
|
|
||||||
function toNumber(value) {
|
function getRates(model) {
|
||||||
const n = Number(value);
|
const m = String(model || '').toLowerCase();
|
||||||
|
if (m.includes('haiku')) return RATE_TABLE.haiku;
|
||||||
|
if (m.includes('opus')) return RATE_TABLE.opus;
|
||||||
|
return RATE_TABLE.sonnet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(v) {
|
||||||
|
const n = Number(v);
|
||||||
return Number.isFinite(n) ? n : 0;
|
return Number.isFinite(n) ? n : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan the session JSONL and sum token usage across all assistant turns.
|
||||||
|
* Returns { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model }
|
||||||
|
* or null on read failure.
|
||||||
|
*/
|
||||||
|
function sumUsageFromTranscript(transcriptPath) {
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = fs.readFileSync(transcriptPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputTokens = 0;
|
||||||
|
let outputTokens = 0;
|
||||||
|
let cacheWriteTokens = 0;
|
||||||
|
let cacheReadTokens = 0;
|
||||||
|
let model = 'unknown';
|
||||||
|
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
let entry;
|
||||||
|
try { entry = JSON.parse(line); } catch { continue; }
|
||||||
|
|
||||||
|
if (entry.type !== 'assistant') continue;
|
||||||
|
const msg = entry.message;
|
||||||
|
if (!msg || !msg.usage) continue;
|
||||||
|
|
||||||
|
const u = msg.usage;
|
||||||
|
inputTokens += toNumber(u.input_tokens);
|
||||||
|
outputTokens += toNumber(u.output_tokens);
|
||||||
|
cacheWriteTokens += toNumber(u.cache_creation_input_tokens);
|
||||||
|
cacheReadTokens += toNumber(u.cache_read_input_tokens);
|
||||||
|
|
||||||
|
if (msg.model && msg.model !== 'unknown') model = msg.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_STDIN = 64 * 1024;
|
||||||
|
let raw = '';
|
||||||
|
|
||||||
process.stdin.setEncoding('utf8');
|
process.stdin.setEncoding('utf8');
|
||||||
process.stdin.on('data', chunk => {
|
process.stdin.on('data', chunk => {
|
||||||
if (raw.length < MAX_STDIN) {
|
if (raw.length < MAX_STDIN) raw += chunk.substring(0, MAX_STDIN - raw.length);
|
||||||
const remaining = MAX_STDIN - raw.length;
|
|
||||||
raw += chunk.substring(0, remaining);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
process.stdin.on('end', () => {
|
process.stdin.on('end', () => {
|
||||||
try {
|
try {
|
||||||
const input = raw.trim() ? JSON.parse(raw) : {};
|
const input = raw.trim() ? JSON.parse(raw) : {};
|
||||||
const usage = input.usage || input.token_usage || {};
|
|
||||||
const inputTokens = toNumber(usage.input_tokens || usage.prompt_tokens || 0);
|
|
||||||
const outputTokens = toNumber(usage.output_tokens || usage.completion_tokens || 0);
|
|
||||||
|
|
||||||
const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown');
|
const transcriptPath = (typeof input.transcript_path === 'string' && input.transcript_path)
|
||||||
|
? input.transcript_path
|
||||||
|
: process.env.CLAUDE_TRANSCRIPT_PATH || null;
|
||||||
|
|
||||||
const sessionId =
|
const sessionId =
|
||||||
sanitizeSessionId(input.session_id) ||
|
sanitizeSessionId(input.session_id) ||
|
||||||
sanitizeSessionId(process.env.ECC_SESSION_ID) ||
|
sanitizeSessionId(process.env.ECC_SESSION_ID) ||
|
||||||
sanitizeSessionId(process.env.CLAUDE_SESSION_ID) ||
|
sanitizeSessionId(process.env.CLAUDE_SESSION_ID) ||
|
||||||
'default';
|
'default';
|
||||||
|
|
||||||
|
let usageTotals = null;
|
||||||
|
if (transcriptPath && fs.existsSync(transcriptPath)) {
|
||||||
|
usageTotals = sumUsageFromTranscript(transcriptPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
inputTokens = 0,
|
||||||
|
outputTokens = 0,
|
||||||
|
cacheWriteTokens = 0,
|
||||||
|
cacheReadTokens = 0,
|
||||||
|
model = 'unknown'
|
||||||
|
} = usageTotals || {};
|
||||||
|
|
||||||
|
const rates = getRates(model);
|
||||||
|
const estimatedCostUsd = Math.round((
|
||||||
|
(inputTokens / 1e6) * rates.in +
|
||||||
|
(outputTokens / 1e6) * rates.out +
|
||||||
|
(cacheWriteTokens / 1e6) * rates.cacheWrite +
|
||||||
|
(cacheReadTokens / 1e6) * rates.cacheRead
|
||||||
|
) * 1e6) / 1e6;
|
||||||
|
|
||||||
const metricsDir = path.join(getClaudeDir(), 'metrics');
|
const metricsDir = path.join(getClaudeDir(), 'metrics');
|
||||||
ensureDir(metricsDir);
|
ensureDir(metricsDir);
|
||||||
|
|
||||||
const row = {
|
const row = {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
|
transcript_path: transcriptPath || '',
|
||||||
model,
|
model,
|
||||||
input_tokens: inputTokens,
|
input_tokens: inputTokens,
|
||||||
output_tokens: outputTokens,
|
output_tokens: outputTokens,
|
||||||
estimated_cost_usd: estimateCost(model, inputTokens, outputTokens)
|
cache_write_tokens: cacheWriteTokens,
|
||||||
|
cache_read_tokens: cacheReadTokens,
|
||||||
|
estimated_cost_usd: estimatedCostUsd
|
||||||
};
|
};
|
||||||
|
|
||||||
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
|
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep hook non-blocking.
|
// Non-blocking — never fail the Stop hook.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pass stdin through (required by ECC hook convention).
|
||||||
process.stdout.write(raw);
|
process.stdout.write(raw);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawnSync } = require('child_process');
|
const { spawnSync } = require('child_process');
|
||||||
|
|
||||||
@@ -71,249 +70,85 @@ function runTests() {
|
|||||||
let passed = 0;
|
let passed = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
if (
|
if (test('blocks protected config file edits through run-with-flags', () => {
|
||||||
test('blocks protected config file edits through run-with-flags', () => {
|
const input = {
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
|
tool_name: 'Write',
|
||||||
try {
|
tool_input: {
|
||||||
const absPath = path.join(tmpDir, '.eslintrc.js');
|
file_path: '.eslintrc.js',
|
||||||
fs.writeFileSync(absPath, 'module.exports = {};');
|
content: 'module.exports = {};'
|
||||||
|
|
||||||
const input = {
|
|
||||||
tool_name: 'Write',
|
|
||||||
tool_input: {
|
|
||||||
file_path: absPath,
|
|
||||||
content: 'module.exports = {};'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = runHook(input);
|
|
||||||
assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked');
|
|
||||||
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
|
||||||
assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// best-effort cleanup
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
};
|
||||||
)
|
|
||||||
passed++;
|
|
||||||
else failed++;
|
|
||||||
|
|
||||||
if (
|
const result = runHook(input);
|
||||||
test('passes through safe file edits unchanged', () => {
|
assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked');
|
||||||
const input = {
|
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
||||||
tool_name: 'Write',
|
assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`);
|
||||||
tool_input: {
|
})) passed++; else failed++;
|
||||||
file_path: 'src/index.js',
|
|
||||||
content: 'console.log("ok");'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawInput = JSON.stringify(input);
|
if (test('passes through safe file edits unchanged', () => {
|
||||||
const result = runHook(input);
|
const input = {
|
||||||
assert.strictEqual(result.code, 0, 'Expected safe file edit to pass');
|
tool_name: 'Write',
|
||||||
assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough');
|
tool_input: {
|
||||||
assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits');
|
file_path: 'src/index.js',
|
||||||
})
|
content: 'console.log("ok");'
|
||||||
)
|
}
|
||||||
passed++;
|
};
|
||||||
else failed++;
|
|
||||||
|
const rawInput = JSON.stringify(input);
|
||||||
|
const result = runHook(input);
|
||||||
|
assert.strictEqual(result.code, 0, 'Expected safe file edit to pass');
|
||||||
|
assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough');
|
||||||
|
assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits');
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('blocks truncated protected config payloads instead of failing open', () => {
|
||||||
|
const rawInput = JSON.stringify({
|
||||||
|
tool_name: 'Write',
|
||||||
|
tool_input: {
|
||||||
|
file_path: '.eslintrc.js',
|
||||||
|
content: 'x'.repeat(1024 * 1024 + 2048)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = runHook(rawInput);
|
||||||
|
assert.strictEqual(result.code, 2, 'Expected truncated protected payload to be blocked');
|
||||||
|
assert.strictEqual(result.stdout, '', 'Blocked truncated payload should not echo raw input');
|
||||||
|
assert.ok(result.stderr.includes('Hook input exceeded 1048576 bytes'), `Expected size warning, got: ${result.stderr}`);
|
||||||
|
assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('legacy hooks do not echo raw input when they fail without stdout', () => {
|
||||||
|
const pluginRoot = path.join(__dirname, '..', `tmp-runner-plugin-${Date.now()}`);
|
||||||
|
const scriptDir = path.join(pluginRoot, 'scripts', 'hooks');
|
||||||
|
const scriptPath = path.join(scriptDir, 'legacy-block.js');
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(scriptDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
scriptPath,
|
||||||
|
'#!/usr/bin/env node\nprocess.stderr.write("blocked by legacy hook\\n");\nprocess.exit(2);\n'
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
|
||||||
test('blocks truncated protected config payloads instead of failing open', () => {
|
|
||||||
const rawInput = JSON.stringify({
|
const rawInput = JSON.stringify({
|
||||||
tool_name: 'Write',
|
tool_name: 'Write',
|
||||||
tool_input: {
|
tool_input: {
|
||||||
file_path: '.eslintrc.js',
|
file_path: '.eslintrc.js',
|
||||||
content: 'x'.repeat(1024 * 1024 + 2048)
|
content: 'module.exports = {};'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = runHook(rawInput);
|
const result = runCustomHook(pluginRoot, 'pre:legacy-block', 'scripts/hooks/legacy-block.js', rawInput);
|
||||||
assert.strictEqual(result.code, 2, 'Expected truncated protected payload to be blocked');
|
assert.strictEqual(result.code, 2, 'Expected failing legacy hook exit code to propagate');
|
||||||
assert.strictEqual(result.stdout, '', 'Blocked truncated payload should not echo raw input');
|
assert.strictEqual(result.stdout, '', 'Expected failing legacy hook to avoid raw passthrough');
|
||||||
assert.ok(result.stderr.includes('Hook input exceeded 1048576 bytes'), `Expected size warning, got: ${result.stderr}`);
|
assert.ok(result.stderr.includes('blocked by legacy hook'), `Expected legacy hook stderr, got: ${result.stderr}`);
|
||||||
assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`);
|
} finally {
|
||||||
})
|
|
||||||
)
|
|
||||||
passed++;
|
|
||||||
else failed++;
|
|
||||||
|
|
||||||
if (
|
|
||||||
test('allows first-time creation of a protected config file', () => {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
|
|
||||||
try {
|
try {
|
||||||
const absPath = path.join(tmpDir, 'eslint.config.mjs');
|
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
||||||
const input = {
|
} catch {
|
||||||
tool_name: 'Write',
|
// best-effort cleanup
|
||||||
tool_input: {
|
|
||||||
file_path: absPath,
|
|
||||||
content: 'export default [];'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawInput = JSON.stringify(input);
|
|
||||||
const result = runHook(input);
|
|
||||||
assert.strictEqual(result.code, 0, `Expected exit 0 for first-time creation, got ${result.code}; stderr: ${result.stderr}`);
|
|
||||||
assert.strictEqual(result.stdout, rawInput, 'Expected raw passthrough when creation is allowed');
|
|
||||||
assert.strictEqual(result.stderr, '', `Expected no stderr for first-time creation, got: ${result.stderr}`);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// best-effort cleanup
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
)
|
})) passed++; else failed++;
|
||||||
passed++;
|
|
||||||
else failed++;
|
|
||||||
|
|
||||||
if (
|
|
||||||
test('allows first-time creation when the parent directory does not exist yet', () => {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
|
|
||||||
try {
|
|
||||||
// Path under a non-existent subdirectory — statSync returns ENOENT
|
|
||||||
// on the final segment, which should be treated as "does not exist"
|
|
||||||
// and allow the write. (Agent or CLI is expected to create parents
|
|
||||||
// during the Write itself; this hook does not need to.)
|
|
||||||
const absPath = path.join(tmpDir, 'no-such-parent', '.prettierrc');
|
|
||||||
const input = {
|
|
||||||
tool_name: 'Write',
|
|
||||||
tool_input: {
|
|
||||||
file_path: absPath,
|
|
||||||
content: '{}'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rawInput = JSON.stringify(input);
|
|
||||||
const result = runHook(input);
|
|
||||||
assert.strictEqual(result.code, 0, `Expected exit 0 for ENOENT path, got ${result.code}; stderr: ${result.stderr}`);
|
|
||||||
assert.strictEqual(result.stdout, rawInput, 'Expected raw passthrough when path does not exist');
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// best-effort cleanup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
passed++;
|
|
||||||
else failed++;
|
|
||||||
|
|
||||||
if (
|
|
||||||
test('blocks protected paths that exist as a dangling symlink', () => {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
|
|
||||||
try {
|
|
||||||
const missingTarget = path.join(tmpDir, 'nowhere.js');
|
|
||||||
const linkPath = path.join(tmpDir, '.eslintrc.js');
|
|
||||||
try {
|
|
||||||
fs.symlinkSync(missingTarget, linkPath);
|
|
||||||
} catch (err) {
|
|
||||||
// Windows without Developer Mode or certain sandboxes disallow
|
|
||||||
// symlinks. Skip cleanly rather than fail the suite.
|
|
||||||
if (err.code === 'EPERM' || err.code === 'EACCES') {
|
|
||||||
console.log(' (skipped: symlink creation not permitted here)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = {
|
|
||||||
tool_name: 'Write',
|
|
||||||
tool_input: {
|
|
||||||
file_path: linkPath,
|
|
||||||
content: 'module.exports = {};'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = runHook(input);
|
|
||||||
assert.strictEqual(result.code, 2, `Expected exit 2 for dangling symlink, got ${result.code}; stderr: ${result.stderr}`);
|
|
||||||
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
|
||||||
assert.ok(
|
|
||||||
result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'),
|
|
||||||
`Expected block message, got: ${result.stderr}`
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// best-effort cleanup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
passed++;
|
|
||||||
else failed++;
|
|
||||||
|
|
||||||
if (
|
|
||||||
test('still blocks writes to an existing protected config file', () => {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
|
|
||||||
try {
|
|
||||||
const absPath = path.join(tmpDir, '.eslintrc.js');
|
|
||||||
fs.writeFileSync(absPath, 'module.exports = { rules: {} };');
|
|
||||||
|
|
||||||
const input = {
|
|
||||||
tool_name: 'Edit',
|
|
||||||
tool_input: {
|
|
||||||
file_path: absPath,
|
|
||||||
content: 'module.exports = { rules: { "no-console": "off" } };'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = runHook(input);
|
|
||||||
assert.strictEqual(result.code, 2, 'Expected exit 2 when modifying an existing protected config');
|
|
||||||
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input');
|
|
||||||
assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// best-effort cleanup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
passed++;
|
|
||||||
else failed++;
|
|
||||||
|
|
||||||
if (
|
|
||||||
test('legacy hooks do not echo raw input when they fail without stdout', () => {
|
|
||||||
const pluginRoot = path.join(__dirname, '..', `tmp-runner-plugin-${Date.now()}`);
|
|
||||||
const scriptDir = path.join(pluginRoot, 'scripts', 'hooks');
|
|
||||||
const scriptPath = path.join(scriptDir, 'legacy-block.js');
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.mkdirSync(scriptDir, { recursive: true });
|
|
||||||
fs.writeFileSync(scriptPath, '#!/usr/bin/env node\nprocess.stderr.write("blocked by legacy hook\\n");\nprocess.exit(2);\n');
|
|
||||||
|
|
||||||
const rawInput = JSON.stringify({
|
|
||||||
tool_name: 'Write',
|
|
||||||
tool_input: {
|
|
||||||
file_path: '.eslintrc.js',
|
|
||||||
content: 'module.exports = {};'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = runCustomHook(pluginRoot, 'pre:legacy-block', 'scripts/hooks/legacy-block.js', rawInput);
|
|
||||||
assert.strictEqual(result.code, 2, 'Expected failing legacy hook exit code to propagate');
|
|
||||||
assert.strictEqual(result.stdout, '', 'Expected failing legacy hook to avoid raw passthrough');
|
|
||||||
assert.ok(result.stderr.includes('blocked by legacy hook'), `Expected legacy hook stderr, got: ${result.stderr}`);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// best-effort cleanup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
passed++;
|
|
||||||
else failed++;
|
|
||||||
|
|
||||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user