From 555611db074eb75a7ad633b7a55d991c29df2203 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 21 Jul 2026 14:24:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai-review):=20=E9=81=BF=E5=85=8D=E5=BB=BA?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E6=A8=A1=E5=BC=8F=E5=9A=B4=E9=87=8D=E7=B5=90?= =?UTF-8?q?=E6=9E=9C=20fail-open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.js | 25 ++++++----- src/lib/diagnostics.js | 71 ++++++++++++++++++++++++++++++ src/lib/review.js | 98 +++++++++++------------------------------- 3 files changed, 110 insertions(+), 84 deletions(-) create mode 100644 src/lib/diagnostics.js diff --git a/src/index.js b/src/index.js index 95b9493..4e5cafd 100644 --- a/src/index.js +++ b/src/index.js @@ -210,7 +210,7 @@ async function main() { }; /** * 建問題模式:建立追蹤 issue(標題=PR 標題、本文=PR 描述+回溯 PR 的引言,連同挑好的標籤一次建立), - * 並把 `issueBuffer` 內暫存的情境留言批次寫入 issue;設定閉包變數 `issue` 供後續留言直接發到 issue。 + * 並把 `issueBuffer` 內暫存的情境留言依流程順序寫入 issue;設定閉包變數 `issue` 供後續留言直接發到 issue。 * 僅於「確定有保留問題」時呼叫一次。標籤於建立時一次帶入,省去「先建空標籤 issue 再補掛」的多餘 API 往返。 * * @param {number[]} [labelIds] - 建立 issue 時要一併掛上的標籤 id 陣列(由 `review.selectLabels` 事先挑選); @@ -224,9 +224,9 @@ async function main() { labels: labelIds, }); log('建問題', 'INF', `已建立追蹤 issue #${issue.number},寫入 ${issueBuffer.length} 則情境留言。`); - await Promise.all( - issueBuffer.map((body) => gitea.createCommentOnIssue(ctx, issue.number, body)), - ); + for (const body of issueBuffer) { + await gitea.createCommentOnIssue(ctx, issue.number, body); + } issueBuffer.length = 0; }; @@ -302,7 +302,9 @@ async function main() { await queueOrPostComment(templates.rolesComment({ title: '🛡️ 防守方登場', roles: defenders })); // ── 步驟 8:防守方裁決 → 排除 → 排序 → 保存 findings ────────────────── - const { kept, excluded } = await review.runDefenders({ tool, model: ctx.model, cwd, defenders, findings }); + const { kept, excluded } = findings.length === 0 + ? { kept: [], excluded: [] } + : await review.runDefenders({ tool, model: ctx.model, cwd, defenders, findings }); review.sortFindings(kept); const relativePath = saveFindings({ cwd, ctx, tool, kept, excluded }); @@ -398,12 +400,15 @@ async function main() { } // ── 收尾:commit 並 push(success=無嚴重問題、failure=有嚴重問題)─────── - // 一般模式:findings+exclusions.json;建問題模式:問題明細已在 issue 留言,只 commit exclusions.json。 + // 一般模式:findings+exclusions.json;建問題模式通常只 commit exclusions.json。 + // 若有嚴重問題,仍 commit findings 檔產生 [failure] 結果 commit,避免相依 API 不支援時 fail-open。 const result = severe.length === 0 ? 'success' : 'failure'; - const filesToCommit = ctx.createIssue ? [] : [relativePath]; - if (exclusionsChanged) { - filesToCommit.push(path.join('.gitea', 'ai-review', 'exclusions.json')); - } + const filesToCommit = review.resultFilesToCommit({ + createIssue: ctx.createIssue, + severeCount: severe.length, + relativePath, + exclusionsChanged, + }); if (filesToCommit.length > 0) { commitFindings({ cwd, ctx, files: filesToCommit, result }); } else { diff --git a/src/lib/diagnostics.js b/src/lib/diagnostics.js new file mode 100644 index 0000000..011cd28 --- /dev/null +++ b/src/lib/diagnostics.js @@ -0,0 +1,71 @@ +'use strict'; + +// AI CLI 失敗診斷與機密遮罩工具:供 review 流程記錄安全、限長的一行錯誤摘要。 + +// 遮罩前先截去的輸入上限,避免對數 MB 失敗輸出跑整份 O(k*n) 正規掃描。 +const AGENT_DIAGNOSTIC_INPUT_LIMIT = 2_000; +// 每段診斷片段(stderr/stdout)寫入日誌的字元上限。 +const AGENT_DIAGNOSTIC_OUTPUT_LIMIT = 500; + +/** + * 遮罩診斷文字中的機密與控制字元,避免寫進 CI log 時外洩。 + * + * 處理順序:換行與控制字元一律壓成單一空白(避免注入假日誌行)→ 遮蔽 + * `Authorization` 標頭、`token=`/`token:` 型憑證、URL 內嵌帳密、以及常見長金鑰/ + * 長 hex/`ghp_` 等 token 樣式。屬「盡力遮罩」——無法窮舉所有機密格式,作為輸出 + * CLI 診斷片段前的防線使用(見 {@link agentFailureDetail})。 + * + * @param {*} text - 待遮罩的原始文字(非字串會先以 `String()` 轉型)。 + * @returns {string} 已去控制字元並遮蔽常見機密樣式的單行文字。 + */ +function redactSecrets(text) { + return String(text ?? '') + .replace(/[\r\n\t\v\f\x00-\x1f\x7f]+/g, ' ') + .replace(/(authorization\s*[:=]\s*)(?:bearer\s+)?\S+/gi, '$1***') + .replace(/((?:api[_-]?key|token|password|secret|bearer)\s*[:=]\s*)\S+/gi, '$1***') + .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/gi, '$1***:***@') + .replace(/\bgh[pousr]_[A-Za-z0-9]{16,}\b/g, '***') + .replace(/\b[A-Za-z0-9_-]{40,}\b/g, '***') + .trim(); +} + +/** + * 從 `runAgent` 的失敗結果組出可診斷的一行摘要:退出碼/訊號為主,原始輸出預設隱藏。 + * + * 安全考量:AI CLI 失敗時可能在 stderr/stdout 回顯提示內容、環境資訊、token、PII 或 + * 原始碼祕密,這些會被長期保存並供多人讀取的 CI log 收錄。因此本函式預設只輸出退出碼、 + * 訊號與逾時狀態;只有 `ACTIONS_STEP_DEBUG=true` 時才附上經 {@link redactSecrets} + * 遮罩且去除控制字元的 stderr/stdout 片段。 + * + * @param {{error: (Error & {code?: number|string, signal?: string, killed?: boolean})|null, stderr?: string, output?: string}} res + * `runAgent` 的回傳物件。 + * @returns {string} 單行診斷摘要(各段以「|」分隔);無任何資訊時回傳固定字串。 + */ +function agentFailureDetail(res) { + const parts = []; + const err = res && res.error; + if (err) { + if (err.killed) parts.push('已逾時終止'); + if (typeof err.code === 'number') parts.push(`exit ${err.code}`); + else if (err.code) parts.push(`code ${err.code}`); + else if (err.signal) parts.push(`signal ${err.signal}`); + } + // 失敗輸出可能含 token 或 PII,預設不寫入長期 CI log;debug 模式才輸出遮罩後片段。 + if (process.env.ACTIONS_STEP_DEBUG === 'true') { + const stderr = redactSecrets(String((res && res.stderr) || '').slice(0, AGENT_DIAGNOSTIC_INPUT_LIMIT)); + if (stderr) parts.push(`stderr:${stderr.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`); + const stdout = redactSecrets(String((res && res.output) || '').slice(0, AGENT_DIAGNOSTIC_INPUT_LIMIT)); + if (stdout) parts.push(`stdout:${stdout.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`); + } + if (parts.length === 0) { + parts.push((err && err.message && redactSecrets(err.message)) || 'AI CLI 執行失敗(無診斷輸出)'); + } + return parts.join('|'); +} + +module.exports = { + AGENT_DIAGNOSTIC_INPUT_LIMIT, + AGENT_DIAGNOSTIC_OUTPUT_LIMIT, + redactSecrets, + agentFailureDetail, +}; diff --git a/src/lib/review.js b/src/lib/review.js index a326caa..db528f6 100644 --- a/src/lib/review.js +++ b/src/lib/review.js @@ -5,6 +5,7 @@ const path = require('path'); const { log, taipeiFromIso, taipeiNow } = require('./log'); const { runAgent, extractJson } = require('./agents'); +const { agentFailureDetail } = require('./diagnostics'); const templates = require('./templates'); // 審查流程核心:.reviewignore 過濾、diff 整理、攻擊方找問題、防守方裁決、排序分組與舊留言處理。 @@ -13,76 +14,6 @@ const templates = require('./templates'); const PER_FILE_DIFF_LIMIT = 16_000; const TOTAL_DIFF_LIMIT = 160_000; -// AI CLI 失敗診斷輸出政策常數(agentFailureDetail 使用):與上方送審上限並列於模組頂層, -// 集中管理長度政策,避免藏在函式中段。 -// INPUT_LIMIT:遮罩前先截去的輸入上限——先截再跑 redactSecrets,避免對數 MB 失敗輸出跑整份 -// O(k×n) 正規掃描;2000 字已足以涵蓋跨界機密樣式。 -const INPUT_LIMIT = 2_000; -// AGENT_DIAGNOSTIC_OUTPUT_LIMIT:每段診斷片段(stderr/stdout)寫入日誌的字元上限,兩段共用同一政策。 -const AGENT_DIAGNOSTIC_OUTPUT_LIMIT = 500; - -/** - * 遮罩單行診斷文字中的機密與控制字元,避免寫進 CI log 時外洩。 - * - * 處理順序:換行與控制字元一律壓成單一空白(避免注入假日誌行)→ 遮蔽 - * `Authorization` 標頭、`token=`/`token:` 型憑證、URL 內嵌帳密、以及常見長金鑰/ - * 長 hex/`ghp_` 等 token 樣式。屬「盡力遮罩」——無法窮舉所有機密格式,作為輸出 - * CLI 診斷片段前的防線使用(見 {@link agentFailureDetail})。 - * - * @param {*} text - 待遮罩的原始文字(非字串會先以 `String()` 轉型)。 - * @returns {string} 已去控制字元並遮蔽常見機密樣式的單行文字。 - * @remarks 本函式未匯出,僅供模組內部使用。 - */ -function redactSecrets(text) { - return String(text ?? '') - .replace(/[\r\n\t\v\f\x00-\x1f\x7f]+/g, ' ') - .replace(/(authorization\s*[:=]\s*)(?:bearer\s+)?\S+/gi, '$1***') - .replace(/((?:api[_-]?key|token|password|secret|bearer)\s*[:=]\s*)\S+/gi, '$1***') - .replace(/(https?:\/\/)[^\s/:@]+:[^\s/@]+@/gi, '$1***:***@') - .replace(/\bgh[pousr]_[A-Za-z0-9]{16,}\b/g, '***') - .replace(/\b[A-Za-z0-9_-]{40,}\b/g, '***') - .trim(); -} - -/** - * 從 `runAgent` 的失敗結果組出可診斷的一行摘要:退出碼/訊號為主,原始輸出預設隱藏。 - * - * 安全考量:AI CLI 失敗時可能在 stderr/stdout 回顯提示內容、環境資訊、token、PII 或 - * 原始碼祕密,這些會被長期保存並供多人讀取的 CI log 收錄。因此本函式預設只輸出退出碼、 - * 訊號與逾時狀態;只有 `ACTIONS_STEP_DEBUG=true` 時才附上經 {@link redactSecrets} - * 遮罩且去除控制字元的 stderr/stdout 片段(各先截去過長輸入再取前 500 字)。 - * - * @param {{error: (Error & {code?: number|string, signal?: string, killed?: boolean})|null, stderr?: string, output?: string}} res - * `runAgent` 的回傳物件。 - * @returns {string} 單行診斷摘要(各段以「|」分隔);無任何資訊時回傳固定字串。 - * @remarks - * 使用情境:{@link runAttackers}/{@link runDefenders}/{@link fillPurposes}/{@link selectLabels} - * 判定 `!res.ok` 時,以本函式把失敗細節寫進 WRN log,讓 CI 記錄能看出 AI CLI 為何失敗; - * 需要輸出片段輔助診斷時,於 workflow 設定 `ACTIONS_STEP_DEBUG=true` 再重跑。 - * 本函式未匯出,僅供模組內部使用。 - */ -function agentFailureDetail(res) { - const parts = []; - const err = res && res.error; - if (err) { - if (err.killed) parts.push('已逾時終止'); - if (typeof err.code === 'number') parts.push(`exit ${err.code}`); - else if (err.code) parts.push(`code ${err.code}`); - else if (err.signal) parts.push(`signal ${err.signal}`); - } - // 失敗輸出可能含 token 或 PII,預設不寫入長期 CI log;debug 模式才輸出遮罩後片段。 - if (process.env.ACTIONS_STEP_DEBUG === 'true') { - const stderr = redactSecrets(String((res && res.stderr) || '').slice(0, INPUT_LIMIT)); - if (stderr) parts.push(`stderr:${stderr.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`); - const stdout = redactSecrets(String((res && res.output) || '').slice(0, INPUT_LIMIT)); - if (stdout) parts.push(`stdout:${stdout.slice(0, AGENT_DIAGNOSTIC_OUTPUT_LIMIT)}`); - } - if (parts.length === 0) { - parts.push((err && err.message && redactSecrets(err.message)) || 'AI CLI 執行失敗(無診斷輸出)'); - } - return parts.join('|'); -} - /** * 讀取工作目錄下的 `.reviewignore`,解析為忽略路徑前綴清單。 * @@ -626,6 +557,28 @@ function appendExclusions({ cwd, excluded, prNumber }) { return true; } +/** + * 依審查模式與結果決定收尾要提交的結果檔。 + * + * 一般模式永遠提交本回合 findings 檔;建問題模式通常把問題明細留在 issue,不提交 + * findings。例外是有嚴重問題時仍提交 findings 檔,讓 `[failure]` 結果 commit 一定能產生, + * 避免問題相依 API 不支援或設定失敗時 PR 缺少失敗檢查。 + * + * @param {Object} params - 解構參數。 + * @param {boolean} params.createIssue - 是否啟用建問題模式。 + * @param {number} params.severeCount - 嚴重 finding 數量。 + * @param {string} params.relativePath - 本回合保存的 findings 檔 repo 相對路徑。 + * @param {boolean} params.exclusionsChanged - exclusions.json 是否有實際異動。 + * @returns {string[]} 應交給 `commitFindings` 的 repo 相對路徑清單。 + */ +function resultFilesToCommit({ createIssue, severeCount, relativePath, exclusionsChanged }) { + const files = createIssue ? (severeCount > 0 ? [relativePath] : []) : [relativePath]; + if (exclusionsChanged) { + files.push(path.join('.gitea', 'ai-review', 'exclusions.json')); + } + return files; +} + /** * 就地排序 findings:依 嚴重→警告→建議、再依檔案路徑、再依起始行遞增。 * @@ -926,13 +879,10 @@ module.exports = { runDefenders, sortFindings, appendExclusions, + resultFilesToCommit, selectLabels, postSevereToIssue, postOthersToIssue, resolveOldComments, postSevereComments, - __test: { - agentFailureDetail, - redactSecrets, - }, };