Compare commits

..

3 Commits

Author SHA1 Message Date
gaurav0107
7145ca9dfe fix(hooks): address coderabbit review — use lstatSync for symlink paths
CodeRabbit major on PR #1898: fs.statSync follows symlinks, so a dangling
protected symlink (e.g. .eslintrc.js pointing at a missing target) would
throw ENOENT and be treated as absent — letting an agent "replace" the
symlink and bypass the protection.

Swap statSync for lstatSync. lstat reports the link node itself regardless
of whether its target exists, so protected entries that happen to be
symlinks stay blocked. ENOENT handling is unchanged: only a genuinely
missing path (no link, no file, no directory) counts as absent.

Add a regression test that creates a dangling symlink at .eslintrc.js and
verifies the hook still blocks Write. Skips cleanly on platforms/sandboxes
that disallow symlink creation (EPERM/EACCES).
2026-05-15 01:09:43 +05:30
gaurav0107
a8fe098c88 fix(hooks): address greptile review — use statSync for true fail-closed
Greptile P1 on PR #1898: fs.existsSync internally catches all errors and
returns false, so the previous try/catch around it was dead code and the
stated "fail-closed on EACCES" semantics weren't actually delivered. A
file under a directory with no execute permission would read as absent
and bypass the guard.

Swap to fs.statSync with explicit ENOENT detection. Only ENOENT flips
exists to false; every other error code (EACCES, EPERM, ELOOP, etc.)
leaves exists=true so the modification guard is never silently weakened.

Add a new test "allows first-time creation when the parent directory
does not exist yet" that exercises the ENOENT path via a non-existent
parent dir — pins the happy path into the regression suite.
2026-05-15 00:57:03 +05:30
gaurav0107
faa51fba11 fix(hooks): allow first-time creation of protected config files
The config-protection hook blocks Write/Edit on any basename in the
PROTECTED_FILES set, regardless of whether the file already exists. The
hook's stated purpose is to prevent agents from softening rules in an
existing config — but the same code path also blocks the legitimate
bootstrap case of scaffolding a linter config into a project that has
none.

Add an fs.existsSync check inside run(): when the basename matches a
protected entry and the file does not yet exist on disk, exit 0 and
let the Write proceed. Keep the exit-2 block for all modifications to
existing files. Stat errors (EACCES, etc.) fail closed — we treat the
path as existing so the guard is never silently weakened.

Update the existing "blocks protected config file edits" test to use a
real temp file so the BLOCK path is still exercised, and add two new
tests covering:

- first-time creation of eslint.config.mjs is allowed (exit 0, raw
  passthrough, no stderr)
- Edit against an existing .eslintrc.js is still blocked (exit 2, no
  stdout, BLOCKED message in stderr)

Fixes #1873
2026-05-15 00:30:23 +05:30
3 changed files with 286 additions and 186 deletions

View File

@@ -7,12 +7,13 @@
* 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) * 0 = allow (not a config file, or first-time creation of one)
* 2 = block (config file modification attempted) * 2 = block (existing 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;
@@ -58,7 +59,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) {
@@ -94,13 +95,41 @@ 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.'
}; };
} }
@@ -125,7 +154,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) {

View File

@@ -1,157 +1,63 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Cost Tracker Hook (v2) * Cost Tracker Hook
* *
* Reads transcript_path from Stop hook stdin, sums usage across all * Appends lightweight session usage metrics to ~/.claude/metrics/costs.jsonl.
* 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');
// Approximate per-1M-token billing rates (USD). const MAX_STDIN = 1024 * 1024;
// Cache creation: 1.25x input rate. Cache read: 0.1x input rate. let raw = '';
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 getRates(model) { function toNumber(value) {
const m = String(model || '').toLowerCase(); const n = Number(value);
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) raw += chunk.substring(0, MAX_STDIN - raw.length); if (raw.length < MAX_STDIN) {
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 transcriptPath = (typeof input.transcript_path === 'string' && input.transcript_path) const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown');
? 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,
cache_write_tokens: cacheWriteTokens, estimated_cost_usd: estimateCost(model, inputTokens, outputTokens)
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 {
// Non-blocking — never fail the Stop hook. // Keep hook non-blocking.
} }
// Pass stdin through (required by ECC hook convention).
process.stdout.write(raw); process.stdout.write(raw);
}); });

View File

@@ -4,6 +4,7 @@
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');
@@ -70,85 +71,249 @@ function runTests() {
let passed = 0; let passed = 0;
let failed = 0; let failed = 0;
if (test('blocks protected config file edits through run-with-flags', () => { if (
const input = { test('blocks protected config file edits through run-with-flags', () => {
tool_name: 'Write', const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-'));
tool_input: { try {
file_path: '.eslintrc.js', const absPath = path.join(tmpDir, '.eslintrc.js');
content: 'module.exports = {};' fs.writeFileSync(absPath, '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++;
const result = runHook(input); if (
assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked'); test('passes through safe file edits unchanged', () => {
assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); const input = {
assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`); tool_name: 'Write',
})) passed++; else failed++; tool_input: {
file_path: 'src/index.js',
content: 'console.log("ok");'
}
};
if (test('passes through safe file edits unchanged', () => { const rawInput = JSON.stringify(input);
const input = { const result = runHook(input);
tool_name: 'Write', assert.strictEqual(result.code, 0, 'Expected safe file edit to pass');
tool_input: { assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough');
file_path: 'src/index.js', assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits');
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: 'module.exports = {};' content: 'x'.repeat(1024 * 1024 + 2048)
} }
}); });
const result = runCustomHook(pluginRoot, 'pre:legacy-block', 'scripts/hooks/legacy-block.js', rawInput); const result = runHook(rawInput);
assert.strictEqual(result.code, 2, 'Expected failing legacy hook exit code to propagate'); assert.strictEqual(result.code, 2, 'Expected truncated protected payload to be blocked');
assert.strictEqual(result.stdout, '', 'Expected failing legacy hook to avoid raw passthrough'); assert.strictEqual(result.stdout, '', 'Blocked truncated payload should not echo raw input');
assert.ok(result.stderr.includes('blocked by legacy hook'), `Expected legacy hook stderr, got: ${result.stderr}`); assert.ok(result.stderr.includes('Hook input exceeded 1048576 bytes'), `Expected size warning, got: ${result.stderr}`);
} finally { assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`);
})
)
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 {
fs.rmSync(pluginRoot, { recursive: true, force: true }); const absPath = path.join(tmpDir, 'eslint.config.mjs');
} catch { const input = {
// best-effort cleanup tool_name: 'Write',
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);