[V10.4-A] 加強 commit-quality Hook + P9 文件歸檔
新增 Edit/Write/MultiEdit 事件攔截(原僅攔截 git commit Bash 指令), 補齊 getenv fallback 模式偵測,防止 hardcoded Token 透過工具直寫入檔案。 - .claude/hooks/commit-quality.js: 改寫為 PreToolUse JSON 格式,覆蓋 Edit/Write/MultiEdit - .claude/settings.json: 新增 Edit|Write|MultiEdit|Bash matcher 註冊 - .claude/hooks/__test__/commit-quality.test.sh: 4 case 自動化測試 - docs/guides/DISK_EXPANSION_GUIDE.md: 磁碟擴充 SOP 歸檔 - docs/p9_completion_report_*.md: P9-1 + P9-2 Sprint 完成報告 - docs/refactor/callback_prefix_proposal.md: 308 按鈕回呼前綴分析(Method C) - docs/refactor/openclaw_bot_routes_split_plan.md: 5999 行神檔拆分計畫 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,63 +1,105 @@
|
||||
/**
|
||||
* commit-quality.js — PreToolUse Hook
|
||||
* 阻擋 debugger 語句 + 硬編碼 Secret 進入 commit。
|
||||
* 已針對 momo 環境加入 Telegram/Gemini/Gitea/Anthropic pattern。
|
||||
* 攔截硬編碼 Secret:
|
||||
* - Bash `git commit`:掃 staged diff(保留舊行為)
|
||||
* - Edit / Write / MultiEdit:掃 new_string / content / edits[].new_string
|
||||
* 輸出遵循官方 PreToolUse 規格:
|
||||
* permissionDecision: "allow" | "deny"
|
||||
*/
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const SECRET_PATTERNS = [
|
||||
[/sk-[a-zA-Z0-9]{20,}/, 'OpenAI API Key'],
|
||||
[/ghp_[a-zA-Z0-9]{36}/, 'GitHub PAT'],
|
||||
[/AKIA[A-Z0-9]{16}/, 'AWS Access Key'],
|
||||
[/AIza[a-zA-Z0-9_-]{35}/, 'Google API Key'],
|
||||
[/\b\d{8,12}:[A-Za-z0-9_-]{35}\b/, 'Telegram Bot Token'],
|
||||
[/TELEGRAM[_\s]*(?:BOT[_\s]*)?TOKEN\s*=\s*["']?[^\s"']{20,}/, 'Telegram Token 環境變數'],
|
||||
[/GEMINI_API_KEY\s*=\s*["']?[A-Za-z0-9_-]{20,}/, 'Gemini API Key'],
|
||||
[/sk-ant-api[0-9a-zA-Z_-]{20,}/, 'Anthropic API Key'],
|
||||
[/glpat-[a-zA-Z0-9_-]{20}/, 'Gitea/GitLab PAT'],
|
||||
[/GITEA[_\s]*TOKEN\s*=\s*["']?[^\s"']{20,}/, 'Gitea Token 環境變數'],
|
||||
// Python fallback default: os.getenv('X', '<secret ≥30 chars>')
|
||||
[/getenv\s*\(\s*[^,)]+,\s*['"][^'"\s]{30,}['"]\s*\)/, 'Python getenv fallback 含疑似機密'],
|
||||
[/os\.environ\.get\s*\(\s*[^,)]+,\s*['"][^'"\s]{30,}['"]\s*\)/, 'os.environ.get fallback 含疑似機密'],
|
||||
];
|
||||
|
||||
function scan(text, fileHint) {
|
||||
if (!text) return [];
|
||||
const hits = [];
|
||||
for (const [pat, label] of SECRET_PATTERNS) {
|
||||
const m = text.match(pat);
|
||||
if (m) hits.push(`${label}${fileHint ? ` in ${fileHint}` : ''}: ${m[0].slice(0, 24)}...`);
|
||||
}
|
||||
// debugger 只對 JS 家族
|
||||
if (fileHint && /\.(js|jsx|ts|tsx)$/.test(fileHint) && /\bdebugger\b/.test(text)) {
|
||||
hits.push(`debugger statement in ${fileHint}`);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function allow() {
|
||||
// 預設 allow:輸出官方規格 JSON
|
||||
const out = {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'allow',
|
||||
},
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function deny(reasons) {
|
||||
const reason = `[commit-quality] 偵測到機密或禁用內容,已阻擋:\n- ${reasons.join('\n- ')}\n請移除後重試。`;
|
||||
process.stderr.write(reason + '\n');
|
||||
const out = {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
permissionDecision: 'deny',
|
||||
permissionDecisionReason: reason,
|
||||
},
|
||||
};
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let d = '';
|
||||
process.stdin.on('data', c => d += c);
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
const i = JSON.parse(d);
|
||||
const cmd = i.tool_input?.command || '';
|
||||
if (!/git commit/.test(cmd) || /--amend/.test(cmd)) { process.stdout.write(d); return; }
|
||||
const i = JSON.parse(d || '{}');
|
||||
const tool = i.tool_name || '';
|
||||
const ti = i.tool_input || {};
|
||||
const hits = [];
|
||||
|
||||
const r = spawnSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], { encoding: 'utf8' });
|
||||
const files = (r.stdout || '').trim().split('\n').filter(Boolean);
|
||||
let blocked = false;
|
||||
if (tool === 'Bash') {
|
||||
const cmd = ti.command || '';
|
||||
if (!/git\s+commit/.test(cmd) || /--amend/.test(cmd)) return allow();
|
||||
|
||||
for (const f of files) {
|
||||
if (!/\.(js|jsx|ts|tsx|py|sh|json|yaml|yml)$/.test(f)) continue;
|
||||
const cr = spawnSync('git', ['show', ':' + f], { encoding: 'utf8' });
|
||||
const c = cr.stdout || '';
|
||||
|
||||
if (/\.(js|jsx|ts|tsx)$/.test(f) && /\bdebugger\b/.test(c)) {
|
||||
process.stderr.write(`[commit-quality] ERROR: debugger statement in ${f}\n`);
|
||||
blocked = true;
|
||||
}
|
||||
|
||||
const secrets = [
|
||||
// 通用 API Keys
|
||||
[/sk-[a-zA-Z0-9]{20,}/, 'OpenAI API Key'],
|
||||
[/ghp_[a-zA-Z0-9]{36}/, 'GitHub PAT'],
|
||||
[/AKIA[A-Z0-9]{16}/, 'AWS Access Key'],
|
||||
[/AIza[a-zA-Z0-9_-]{35}/, 'Google API Key'],
|
||||
// momo 專屬
|
||||
[/\d{8,12}:[A-Za-z0-9_-]{35}/, 'Telegram Bot Token'],
|
||||
[/TELEGRAM[_\s]*(?:BOT[_\s]*)?TOKEN\s*=\s*["']?[^\s"']{20,}/, 'Telegram Token 環境變數'],
|
||||
[/GEMINI_API_KEY\s*=\s*["']?[A-Za-z0-9_-]{20,}/, 'Gemini API Key'],
|
||||
[/sk-ant-api[0-9a-zA-Z_-]{20,}/, 'Anthropic API Key'],
|
||||
[/glpat-[a-zA-Z0-9_-]{20}/, 'Gitea/GitLab PAT'],
|
||||
[/GITEA[_\s]*TOKEN\s*=\s*["']?[^\s"']{20,}/, 'Gitea Token 環境變數'],
|
||||
];
|
||||
|
||||
for (const [pattern, label] of secrets) {
|
||||
if (pattern.test(c)) {
|
||||
process.stderr.write(`[commit-quality] ERROR: 偵測到 ${label} in ${f}\n`);
|
||||
blocked = true;
|
||||
}
|
||||
const r = spawnSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], { encoding: 'utf8' });
|
||||
const files = (r.stdout || '').trim().split('\n').filter(Boolean);
|
||||
for (const f of files) {
|
||||
if (!/\.(js|jsx|ts|tsx|py|sh|json|yaml|yml|env|toml|ini|conf)$/.test(f)) continue;
|
||||
const cr = spawnSync('git', ['show', ':' + f], { encoding: 'utf8' });
|
||||
hits.push(...scan(cr.stdout || '', f));
|
||||
}
|
||||
} else if (tool === 'Write') {
|
||||
hits.push(...scan(ti.content || '', ti.file_path));
|
||||
} else if (tool === 'Edit') {
|
||||
hits.push(...scan(ti.new_string || '', ti.file_path));
|
||||
} else if (tool === 'MultiEdit') {
|
||||
const edits = Array.isArray(ti.edits) ? ti.edits : [];
|
||||
for (const e of edits) hits.push(...scan(e.new_string || '', ti.file_path));
|
||||
} else {
|
||||
return allow();
|
||||
}
|
||||
|
||||
if (blocked) {
|
||||
process.stderr.write('[commit-quality] Commit 已阻擋。請移除上述敏感資訊後重試。\n');
|
||||
process.exit(2);
|
||||
}
|
||||
if (hits.length) return deny(hits);
|
||||
return allow();
|
||||
} catch (e) {
|
||||
process.stderr.write(`[commit-quality] hook internal error: ${e.message}\n`);
|
||||
// Hook 內部錯誤不應阻擋 commit,但要留下記錄
|
||||
// 若希望更保守(阻擋),改為 process.exit(1)
|
||||
// 內部錯誤不阻擋,避免誤殺
|
||||
return allow();
|
||||
}
|
||||
process.stdout.write(d);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user