fix(ai-review): 避免建問題模式嚴重結果 fail-open
This commit is contained in:
+15
-10
@@ -210,7 +210,7 @@ async function main() {
|
|||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* 建問題模式:建立追蹤 issue(標題=PR 標題、本文=PR 描述+回溯 PR 的引言,連同挑好的標籤一次建立),
|
* 建問題模式:建立追蹤 issue(標題=PR 標題、本文=PR 描述+回溯 PR 的引言,連同挑好的標籤一次建立),
|
||||||
* 並把 `issueBuffer` 內暫存的情境留言批次寫入 issue;設定閉包變數 `issue` 供後續留言直接發到 issue。
|
* 並把 `issueBuffer` 內暫存的情境留言依流程順序寫入 issue;設定閉包變數 `issue` 供後續留言直接發到 issue。
|
||||||
* 僅於「確定有保留問題」時呼叫一次。標籤於建立時一次帶入,省去「先建空標籤 issue 再補掛」的多餘 API 往返。
|
* 僅於「確定有保留問題」時呼叫一次。標籤於建立時一次帶入,省去「先建空標籤 issue 再補掛」的多餘 API 往返。
|
||||||
*
|
*
|
||||||
* @param {number[]} [labelIds] - 建立 issue 時要一併掛上的標籤 id 陣列(由 `review.selectLabels` 事先挑選);
|
* @param {number[]} [labelIds] - 建立 issue 時要一併掛上的標籤 id 陣列(由 `review.selectLabels` 事先挑選);
|
||||||
@@ -224,9 +224,9 @@ async function main() {
|
|||||||
labels: labelIds,
|
labels: labelIds,
|
||||||
});
|
});
|
||||||
log('建問題', 'INF', `已建立追蹤 issue #${issue.number},寫入 ${issueBuffer.length} 則情境留言。`);
|
log('建問題', 'INF', `已建立追蹤 issue #${issue.number},寫入 ${issueBuffer.length} 則情境留言。`);
|
||||||
await Promise.all(
|
for (const body of issueBuffer) {
|
||||||
issueBuffer.map((body) => gitea.createCommentOnIssue(ctx, issue.number, body)),
|
await gitea.createCommentOnIssue(ctx, issue.number, body);
|
||||||
);
|
}
|
||||||
issueBuffer.length = 0;
|
issueBuffer.length = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -302,7 +302,9 @@ async function main() {
|
|||||||
await queueOrPostComment(templates.rolesComment({ title: '🛡️ 防守方登場', roles: defenders }));
|
await queueOrPostComment(templates.rolesComment({ title: '🛡️ 防守方登場', roles: defenders }));
|
||||||
|
|
||||||
// ── 步驟 8:防守方裁決 → 排除 → 排序 → 保存 findings ──────────────────
|
// ── 步驟 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);
|
review.sortFindings(kept);
|
||||||
const relativePath = saveFindings({ cwd, ctx, tool, kept, excluded });
|
const relativePath = saveFindings({ cwd, ctx, tool, kept, excluded });
|
||||||
|
|
||||||
@@ -398,12 +400,15 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 收尾:commit 並 push(success=無嚴重問題、failure=有嚴重問題)───────
|
// ── 收尾: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 result = severe.length === 0 ? 'success' : 'failure';
|
||||||
const filesToCommit = ctx.createIssue ? [] : [relativePath];
|
const filesToCommit = review.resultFilesToCommit({
|
||||||
if (exclusionsChanged) {
|
createIssue: ctx.createIssue,
|
||||||
filesToCommit.push(path.join('.gitea', 'ai-review', 'exclusions.json'));
|
severeCount: severe.length,
|
||||||
}
|
relativePath,
|
||||||
|
exclusionsChanged,
|
||||||
|
});
|
||||||
if (filesToCommit.length > 0) {
|
if (filesToCommit.length > 0) {
|
||||||
commitFindings({ cwd, ctx, files: filesToCommit, result });
|
commitFindings({ cwd, ctx, files: filesToCommit, result });
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
+24
-74
@@ -5,6 +5,7 @@ const path = require('path');
|
|||||||
|
|
||||||
const { log, taipeiFromIso, taipeiNow } = require('./log');
|
const { log, taipeiFromIso, taipeiNow } = require('./log');
|
||||||
const { runAgent, extractJson } = require('./agents');
|
const { runAgent, extractJson } = require('./agents');
|
||||||
|
const { agentFailureDetail } = require('./diagnostics');
|
||||||
const templates = require('./templates');
|
const templates = require('./templates');
|
||||||
|
|
||||||
// 審查流程核心:.reviewignore 過濾、diff 整理、攻擊方找問題、防守方裁決、排序分組與舊留言處理。
|
// 審查流程核心:.reviewignore 過濾、diff 整理、攻擊方找問題、防守方裁決、排序分組與舊留言處理。
|
||||||
@@ -13,76 +14,6 @@ const templates = require('./templates');
|
|||||||
const PER_FILE_DIFF_LIMIT = 16_000;
|
const PER_FILE_DIFF_LIMIT = 16_000;
|
||||||
const TOTAL_DIFF_LIMIT = 160_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`,解析為忽略路徑前綴清單。
|
* 讀取工作目錄下的 `.reviewignore`,解析為忽略路徑前綴清單。
|
||||||
*
|
*
|
||||||
@@ -626,6 +557,28 @@ function appendExclusions({ cwd, excluded, prNumber }) {
|
|||||||
return true;
|
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:依 嚴重→警告→建議、再依檔案路徑、再依起始行遞增。
|
* 就地排序 findings:依 嚴重→警告→建議、再依檔案路徑、再依起始行遞增。
|
||||||
*
|
*
|
||||||
@@ -926,13 +879,10 @@ module.exports = {
|
|||||||
runDefenders,
|
runDefenders,
|
||||||
sortFindings,
|
sortFindings,
|
||||||
appendExclusions,
|
appendExclusions,
|
||||||
|
resultFilesToCommit,
|
||||||
selectLabels,
|
selectLabels,
|
||||||
postSevereToIssue,
|
postSevereToIssue,
|
||||||
postOthersToIssue,
|
postOthersToIssue,
|
||||||
resolveOldComments,
|
resolveOldComments,
|
||||||
postSevereComments,
|
postSevereComments,
|
||||||
__test: {
|
|
||||||
agentFailureDetail,
|
|
||||||
redactSecrets,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user