mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
fix(hooks,lib): fix hook detection and parsing edge cases (#2405)
* fix(hooks,lib): fix hook detection and parsing edge cases
- auto-tmux-dev: dev\b -> dev(?![\w-]) so one-shot dev-build/dev-docs scripts
are not detached into tmux; align command shapes (yarn run dev, bun dev) with
pre-bash-dev-server-block.js DEV_PATTERN.
- pre-bash-commit-quality: skip obvious non-secret placeholders (env refs,
${...}, <...>, whitelisted tokens) in the api-key rule without suppressing
real high-entropy secrets; make -m message extraction quote- and
escaped-quote-aware so `-m "fix: \"x\""` / apostrophes are not truncated.
- pre-compact: annotate the CURRENT worktree's session (match **Worktree:** /
legacy **Project:**) instead of the newest *-session.tmp across all projects,
layered onto the LLM-summary flow from #2388; a present-but-blank Worktree
header is treated as non-legacy (no foreign project fallback).
- shell-substitution: stop double-appending a trailing backslash in an
unterminated backtick span.
- utils readStdinJson: on overflow, settle and resolve {} immediately (clear
timer + listeners) instead of waiting for end/timeout and parsing a partial
prefix; surface the overflow on stderr.
Regression tests added/extended (new tests/hooks/pre-compact.test.js).
Addresses review feedback on #2405. The earlier block-no-verify change was
dropped: its message-value skip on merge/cherry-pick/am/rebase would let
`git rebase -m --no-verify` bypass the hook (rebase's -m is the boolean
--merge), a false-negative worse than the contrived false-positive it fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ci): align hook fixtures and drain oversized stdin
---------
Co-authored-by: djpjronline-netizen <276112803+djpjronline-netizen@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
djpjronline-netizen
haelyra
parent
6be87a56ae
commit
837acaf20b
@@ -36,9 +36,18 @@ function run(rawInput) {
|
||||
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
|
||||
const cmd = input.tool_input?.command || '';
|
||||
|
||||
// Detect dev server commands: npm run dev, pnpm dev, yarn dev, bun run dev
|
||||
// Use word boundary (\b) to avoid matching partial commands
|
||||
const devServerRegex = /(npm run dev\b|pnpm( run)? dev\b|yarn dev\b|bun run dev\b)/;
|
||||
// Detect dev server commands: npm run dev, pnpm (run) dev, yarn (run) dev,
|
||||
// bun (run) dev. Trailing (?![\w-]) rather than \b: \b treats a hyphen as a
|
||||
// word boundary, so `dev\b` matches the `dev` prefix of distinct scripts
|
||||
// like `dev-build` / `dev-docs` and would wrongly detach those one-shot
|
||||
// scripts into tmux. The lookahead still matches the dev server (`dev`,
|
||||
// `dev:ssr`, ...) but not a `dev-<suffix>` script. The optional `run` on
|
||||
// yarn/bun mirrors the command shapes in pre-bash-dev-server-block.js
|
||||
// DEV_PATTERN so the two hooks agree on what counts as a dev server.
|
||||
// Flexible whitespace (\s+) and leading \b make this byte-identical to
|
||||
// pre-bash-dev-server-block.js DEV_PATTERN, so a tabbed/multi-space command
|
||||
// the blocker catches is also detached here (they agree exactly).
|
||||
const devServerRegex = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn(?:\s+run)?\s+dev|bun(?:\s+run)?\s+dev)(?![\w-])/;
|
||||
|
||||
if (devServerRegex.test(cmd)) {
|
||||
// Get session name from current directory basename, sanitize for shell safety
|
||||
|
||||
@@ -57,9 +57,32 @@ function shouldCheckFile(filePath) {
|
||||
return checkableExtensions.some(ext => filePath.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a captured api-key value is an OBVIOUS non-secret placeholder so
|
||||
* the heuristic generic api-key rule does not emit a false positive. Deliberately
|
||||
* narrow: only suppresses whole-value env references / interpolations / angle-bracket
|
||||
* tokens and a short explicit whitelist of placeholder + env-var NAME tokens. It must
|
||||
* NOT suppress arbitrary high-entropy data (uppercase-hex, base32, digit-only, mixed
|
||||
* tokens), since the generic rule is the only net catching non-prefixed secrets and a
|
||||
* false-negative there is the safety-critical failure this hook exists to prevent.
|
||||
* @param {string} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPlaceholderSecret(value) {
|
||||
const v = (value || '').trim();
|
||||
if (v.length === 0) return true; // empty value
|
||||
if (/^process\.env\.[A-Za-z0-9_]+$/.test(v)) return true; // entire value is a process.env.NAME reference
|
||||
if (/^\$\{[^}]*\}$/.test(v)) return true; // entire value is a ${...} interpolation
|
||||
if (/^<[^<>]*>$/.test(v)) return true; // entire value is a <PLACEHOLDER> token
|
||||
// Short explicit whitelist of placeholder + env-var NAME tokens (whole-value match only).
|
||||
// No general all-caps clause: real all-caps/hex/base32/digit secrets must still flag.
|
||||
if (/^(REPLACE_ME|CHANGE_?ME|YOUR[_-]?API[_-]?KEY|YOUR[_-]?KEY[_-]?HERE|API[_-]?KEY|SECRET|TOKEN|KEY|TODO|TBD|FIXME|XXX+)$/i.test(v)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find issues in file content
|
||||
* @param {string} filePath
|
||||
* @param {string} filePath
|
||||
* @returns {object[]} Array of issues found
|
||||
*/
|
||||
function findFileIssues(filePath) {
|
||||
@@ -112,11 +135,21 @@ function findFileIssues(filePath) {
|
||||
{ pattern: /sk-[a-zA-Z0-9]{20,}/, name: 'OpenAI API key' },
|
||||
{ pattern: /ghp_[a-zA-Z0-9]{36}/, name: 'GitHub PAT' },
|
||||
{ pattern: /AKIA[A-Z0-9]{16}/, name: 'AWS Access Key' },
|
||||
{ pattern: /api[_-]?key\s*[=:]\s*['"][^'"]+['"]/i, name: 'API key' }
|
||||
// Capture the quoted value so obvious non-secret placeholders can be excluded
|
||||
{ pattern: /api[_-]?key\s*[=:]\s*['"]([^'"]+)['"]/i, name: 'API key', valueGroup: 1 },
|
||||
// Unquoted form (API_KEY=..., api_key: ... without quotes). Scoped to a
|
||||
// single alnum/underscore/hyphen token of 12+ chars containing at least
|
||||
// one digit — real secrets are near-always alphanumeric, whereas bare
|
||||
// identifiers/expressions common in this hook's checkable languages
|
||||
// (config.apiKey, getApiKey(), process.env.API_KEY) are pure-alpha or
|
||||
// contain '.'/'(' that fall outside the character class, so they don't
|
||||
// match. Kept deliberately narrow to avoid flagging ordinary code.
|
||||
{ pattern: /api[_-]?key\s*[=:]\s*(?!['"])((?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{12,})/i, name: 'API key', valueGroup: 1 }
|
||||
];
|
||||
|
||||
for (const { pattern, name } of secretPatterns) {
|
||||
if (pattern.test(line)) {
|
||||
for (const { pattern, name, valueGroup } of secretPatterns) {
|
||||
const secretMatch = line.match(pattern);
|
||||
if (secretMatch && !(valueGroup && isPlaceholderSecret(secretMatch[valueGroup]))) {
|
||||
issues.push({
|
||||
type: 'secret',
|
||||
message: `Potential ${name} exposed at line ${lineNum}`,
|
||||
@@ -139,11 +172,14 @@ function findFileIssues(filePath) {
|
||||
* @returns {object|null} Validation result or null if no message to validate
|
||||
*/
|
||||
function validateCommitMessage(command) {
|
||||
// Extract commit message from command
|
||||
const messageMatch = command.match(/(?:-m|--message)[=\s]+["']?([^"']+)["']?/);
|
||||
// Extract commit message from command (quote-aware: when quoted, capture to the
|
||||
// matching closing quote, consuming escaped chars (\") so an embedded escaped
|
||||
// quote does not truncate the subject, and allowing the OTHER quote char inside
|
||||
// the body; when unquoted, capture the full remaining tail, not just the first token)
|
||||
const messageMatch = command.match(/(?:-m|--message)[=\s]+(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^"']+?)\s*$)/);
|
||||
if (!messageMatch) return null;
|
||||
|
||||
const message = messageMatch[1];
|
||||
const message = messageMatch[1] ?? messageMatch[2] ?? messageMatch[3];
|
||||
const issues = [];
|
||||
|
||||
// Check conventional commit format
|
||||
@@ -445,4 +481,4 @@ if (require.main === module) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { run, evaluate };
|
||||
module.exports = { run, evaluate, validateCommitMessage, findFileIssues, isPlaceholderSecret };
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { getSessionsDir, getDateTimeString, getTimeString, findFiles, ensureDir, appendFile, readFile, writeFile, log } = require('../lib/utils');
|
||||
const { getSessionsDir, getDateTimeString, getTimeString, findFiles, ensureDir, appendFile, readFile, writeFile, getProjectName, log } = require('../lib/utils');
|
||||
const { generateSessionSummary } = require('../lib/llm-summary');
|
||||
|
||||
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
|
||||
@@ -24,22 +24,91 @@ function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a path (resolve symlinks); fall back to the input on failure.
|
||||
* Mirrors session-start.js#normalizePath so worktree comparisons agree.
|
||||
*/
|
||||
function normalizePath(p) {
|
||||
try {
|
||||
return fs.realpathSync(p);
|
||||
} catch {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the session file that belongs to the CURRENT worktree.
|
||||
*
|
||||
* The sessions dir is shared across every project/worktree, so the newest
|
||||
* `*-session.tmp` is frequently a DIFFERENT project's session. Matching by
|
||||
* mtime (`sessions[0]`) therefore writes the compaction summary into the wrong
|
||||
* project. Match on the `**Worktree:**` header (written by session-end.js)
|
||||
* against cwd, mirroring session-start.js#selectMatchingSession:
|
||||
* 1. exact worktree (cwd) match — newest wins
|
||||
* 2. truly legacy sessions with NO Worktree header: same **Project:** name
|
||||
* 3. otherwise null — do NOT annotate a foreign worktree's session
|
||||
* A present-but-blank Worktree header counts as non-legacy (never a project
|
||||
* fallback), so a foreign session is not matched by name.
|
||||
*
|
||||
* @param {Array<{path: string}>} sessions - newest-first session list
|
||||
* @param {string} cwd
|
||||
* @param {string} currentProject
|
||||
* @param {(p: string) => (string|null)} [readFn]
|
||||
* @returns {string|null} path of the chosen session, or null if none match
|
||||
*/
|
||||
function selectActiveSessionPath(sessions, cwd, currentProject, readFn = readFile) {
|
||||
if (!sessions || sessions.length === 0) return null;
|
||||
const normalizedCwd = normalizePath(cwd);
|
||||
let projectMatch = null;
|
||||
|
||||
for (const session of sessions) {
|
||||
const content = readFn(session.path);
|
||||
if (!content) continue;
|
||||
|
||||
// (.*) not (.+): an explicit but empty header (`**Worktree:**` / `**Worktree:**\n`)
|
||||
// must still register as present (hasWorktreeHeader) so it does not fall back
|
||||
// to project-name matching against a foreign session.
|
||||
const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.*)$/m);
|
||||
const hasWorktreeHeader = Boolean(worktreeMatch);
|
||||
const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : '';
|
||||
|
||||
if (sessionWorktree && normalizePath(sessionWorktree) === normalizedCwd) {
|
||||
return session.path;
|
||||
}
|
||||
|
||||
// Project-name fallback only for truly legacy sessions with NO Worktree
|
||||
// header at all — a present-but-blank header is not treated as legacy.
|
||||
if (!projectMatch && currentProject && !hasWorktreeHeader) {
|
||||
const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m);
|
||||
const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : '';
|
||||
if (sessionProject && sessionProject === currentProject) {
|
||||
projectMatch = session.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return projectMatch;
|
||||
}
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let stdinData = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
|
||||
process.stdin.on('data', chunk => {
|
||||
if (stdinData.length < MAX_STDIN) {
|
||||
stdinData += chunk.substring(0, MAX_STDIN - stdinData.length);
|
||||
}
|
||||
});
|
||||
if (require.main === module) {
|
||||
process.stdin.setEncoding('utf8');
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
main().catch(err => {
|
||||
log(`[PreCompact] Error: ${err.message}`);
|
||||
process.exit(0);
|
||||
process.stdin.on('data', chunk => {
|
||||
if (stdinData.length < MAX_STDIN) {
|
||||
stdinData += chunk.substring(0, MAX_STDIN - stdinData.length);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
main().catch(err => {
|
||||
log(`[PreCompact] Error: ${err.message}`);
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let transcriptPath = null;
|
||||
@@ -66,7 +135,14 @@ async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const activeSession = sessions[0].path;
|
||||
// Select the session for THIS worktree, not merely the newest across all
|
||||
// projects (the sessions dir is shared). Skip when none matches rather than
|
||||
// writing the summary into a foreign project's session file.
|
||||
const activeSession = selectActiveSessionPath(sessions, process.cwd(), getProjectName());
|
||||
if (!activeSession) {
|
||||
log('[PreCompact] No session matches the current worktree; skipping annotation');
|
||||
process.exit(0);
|
||||
}
|
||||
const timeStr = getTimeString();
|
||||
|
||||
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
||||
@@ -98,3 +174,5 @@ async function main() {
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
module.exports = { selectActiveSessionPath, normalizePath };
|
||||
|
||||
@@ -55,8 +55,12 @@ function extractCommandSubstitutions(input) {
|
||||
if (i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
} else {
|
||||
// Trailing backslash at end of an unterminated span: advance past
|
||||
// it so it is not appended a second time by the fallthrough below.
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inner === '`') {
|
||||
break;
|
||||
@@ -85,8 +89,12 @@ function extractCommandSubstitutions(input) {
|
||||
if (i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
} else {
|
||||
// Trailing backslash at end of an unterminated span: advance past
|
||||
// it so it is not appended a second time by the fallthrough below.
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
|
||||
bodyInSingle = !bodyInSingle;
|
||||
@@ -213,8 +221,12 @@ function extractSubshellGroups(input) {
|
||||
if (i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
} else {
|
||||
// Trailing backslash at end of an unterminated span: advance past
|
||||
// it so it is not appended a second time by the fallthrough below.
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
|
||||
bodyInSingle = !bodyInSingle;
|
||||
@@ -374,8 +386,12 @@ function extractBraceGroups(input) {
|
||||
if (i + 1 < source.length) {
|
||||
body += source[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
} else {
|
||||
// Trailing backslash at end of an unterminated span: advance past
|
||||
// it so it is not appended a second time by the fallthrough below.
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inner === "'" && !bodyInDouble && innerPrev !== '\\') {
|
||||
bodyInSingle = !bodyInSingle;
|
||||
|
||||
+33
-5
@@ -284,6 +284,7 @@ async function readStdinJson(options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
let data = '';
|
||||
let settled = false;
|
||||
let overflowed = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
@@ -293,7 +294,12 @@ async function readStdinJson(options = {}) {
|
||||
process.stdin.removeAllListeners('end');
|
||||
process.stdin.removeAllListeners('error');
|
||||
if (process.stdin.unref) process.stdin.unref();
|
||||
// Resolve with whatever we have so far rather than hanging
|
||||
// Oversized input is always rejected. Otherwise, resolve with whatever
|
||||
// arrived before the timeout rather than hanging.
|
||||
if (overflowed) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(data.trim() ? JSON.parse(data) : {});
|
||||
} catch {
|
||||
@@ -304,15 +310,34 @@ async function readStdinJson(options = {}) {
|
||||
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', chunk => {
|
||||
if (data.length < maxSize) {
|
||||
data += chunk;
|
||||
if (settled) return;
|
||||
if (overflowed) return;
|
||||
// Mark oversized input as rejected and discard the buffered prefix.
|
||||
// Continue consuming the stream without retaining later chunks so a
|
||||
// finite parent can finish writing without EPIPE. Resolution happens at
|
||||
// EOF or the existing timeout, which also bounds never-closing writers.
|
||||
if (data.length + chunk.length > maxSize) {
|
||||
overflowed = true;
|
||||
data = '';
|
||||
process.stderr.write(
|
||||
`[readStdinJson] stdin exceeded ${maxSize} bytes; input truncated and treated as empty\n`
|
||||
);
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
clearTimeout(timer);
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (overflowed) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(data.trim() ? JSON.parse(data) : {});
|
||||
} catch {
|
||||
@@ -323,7 +348,10 @@ async function readStdinJson(options = {}) {
|
||||
});
|
||||
|
||||
process.stdin.on('error', () => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
clearTimeout(timer);
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
// Resolve with empty object so hooks don't crash on stdin errors
|
||||
|
||||
Reference in New Issue
Block a user