[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:
ogt
2026-04-25 01:42:40 +08:00
parent e9b2dabffd
commit fcac03379d
9 changed files with 1218 additions and 53 deletions

View File

@@ -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);
});