原本一次 fetch 失敗(逾時/連線中斷/非 2xx)就直接視為查無遠端版本並擋下工具呼叫, 對暫時性網路問題過度敏感;改為最多重試 3 次、每次間隔 300ms,仍全部失敗才 fail-closed。 TLS 憑證驗證全程不跳過,維持 --use-system-ca 既有信任機制;--timeout-ms 預設由 5000 降到 3000,讓重試後的最長總耗時仍落在 hooks.json 設定的 15 秒逾時內。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
8.5 KiB
JavaScript
210 lines
8.5 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
/**
|
||
* version-guard.mjs — 依 /jsc-shared:spec-version-guard 規範,比對「遠端發佈版本(Gitea
|
||
* master 的 plugin.json)」與「當前實際載入版本(本次呼叫實際讀到的 plugin 目錄)」,
|
||
* 兩者不同或查不到遠端版本一律 fail-closed(exit 非 0)。可被 hook(PreToolUse/SessionStart)
|
||
* 與 agent/CLI 兩種路徑呼叫。
|
||
*
|
||
* 用法:
|
||
* node version-guard.mjs [--plugin-root <path>] [--host <host>] [--timeout-ms <ms>]
|
||
*
|
||
* --plugin-root 本地 plugin 根目錄(含 plugin.json)。省略時使用 $CLAUDE_PLUGIN_ROOT;
|
||
* 兩者皆無則視為無法判定,fail-closed。
|
||
* --host Gitea 主機,省略時依序取 $GITEA_HOST,再退回 gitea.jsc.idv.tw。
|
||
* --timeout-ms 遠端查詢單次逾時毫秒數,預設 3000(最多重試 3 次,見下方「重試」說明)。
|
||
*
|
||
* 兩種呼叫模式(自動偵測,不需額外旗標):
|
||
* 1. hook 模式:stdin 餵進合法 JSON(Claude Code/Copilot 的 PreToolUse event)時,
|
||
* 改用 `persona/hooks/_hook.mjs` 已驗證可行的 stdout JSON 回應機制
|
||
* (`{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny"|"allow",...}}`),
|
||
* exit code 固定 0(是否阻擋由 JSON 內容決定,不是 exit code)。
|
||
* 2. CLI/測試模式:stdin 沒有合法 JSON(互動終端機或空管線)時,用 exit code 表示結果
|
||
* (0=相符,非 0=阻擋),訊息走 stderr——供人工測試與端到端驗證使用。
|
||
*
|
||
* 更新時間:2026/08/19 11:39:27
|
||
* 相依:Node.js 標準內建功能(fetch/fs/path),無外部套件;需 Node ≥ 20.12 並以
|
||
* `node --use-system-ca` 啟動,讓 fetch 走系統信任的 CA store(而非 Node 內建的
|
||
* Mozilla CA 清單)——在企業網路/代理攔截 TLS 的環境下,curl 等工具通常已信任
|
||
* 系統憑證,但 Node fetch 預設不會,若不加這個旗標會誤判成「查無法取得遠端版本」
|
||
* 而非真正的版本落差;呼叫端(hooks.json)的 command 必須帶上此旗標。TLS 憑證驗證
|
||
* 全程不跳過——此腳本的查詢結果直接決定要不要放行工具呼叫,跳過驗證等於讓中間人可
|
||
* 偽造回應誘導誤判,risk 遠大於單次連線失敗;遇到暫時性網路問題改用下方重試機制處理。
|
||
* 重試:遠端查詢對暫時性失敗(逾時/連線中斷/非 2xx/回應格式錯誤)最多重試到
|
||
* REMOTE_FETCH_MAX_ATTEMPTS 次,每次間隔 REMOTE_FETCH_RETRY_DELAY_MS,仍全部失敗才
|
||
* 視為「查無法取得遠端版本」fail-closed;--timeout-ms 預設從 5000 降到 3000,讓
|
||
* 「重試次數 × 單次逾時+重試間隔」的最長總耗時控制在 hooks.json 設定的 15 秒內。
|
||
* 機密:不涉及 token(遠端 raw plugin.json 端點匿名可讀),輸出仍套用
|
||
* shared/scripts/lib/redact-patterns.json 的遮蔽規則以防萬一。
|
||
* 退出碼(CLI/測試模式):0=版本相符;2=版本不符、查不到遠端、或本地版本無法判定(皆為 fail-closed)。
|
||
* hook 模式固定 exit 0,改用 stdout JSON 的 permissionDecision 表示阻擋與否(見上方兩種呼叫模式)。
|
||
*/
|
||
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { log } from './lib/log.mjs';
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||
const REDACT_RULES = JSON.parse(fs.readFileSync(path.join(__dirname, 'lib', 'redact-patterns.json'), 'utf8')).rules;
|
||
|
||
function redact(text) {
|
||
let out = text;
|
||
for (const rule of REDACT_RULES) {
|
||
out = out.replace(new RegExp(rule.pattern, 'g'), rule.replacement);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const REMOTE_FETCH_MAX_ATTEMPTS = 3;
|
||
const REMOTE_FETCH_RETRY_DELAY_MS = 300;
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
function parseArgs(argv) {
|
||
const args = { pluginRoot: null, host: null, timeoutMs: 3000 };
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const a = argv[i];
|
||
if (a === '--plugin-root') args.pluginRoot = argv[++i];
|
||
else if (a === '--host') args.host = argv[++i];
|
||
else if (a === '--timeout-ms') args.timeoutMs = Number(argv[++i]);
|
||
else {
|
||
log('ERR', `未知參數:${redact(a)}`, '版本檢查');
|
||
process.exit(1);
|
||
}
|
||
}
|
||
return args;
|
||
}
|
||
|
||
function resolvePluginRoot(args) {
|
||
return args.pluginRoot || process.env.CLAUDE_PLUGIN_ROOT || null;
|
||
}
|
||
|
||
function resolveHost(args) {
|
||
return args.host || process.env.GITEA_HOST || 'gitea.jsc.idv.tw';
|
||
}
|
||
|
||
/** 從本地 plugin.json 的 name(例如 "jsc-shared")推得 repo 短名("shared")。 */
|
||
function shortNameFromPluginName(name) {
|
||
return name.startsWith('jsc-') ? name.slice('jsc-'.length) : name;
|
||
}
|
||
|
||
async function fetchRemoteVersionOnce(host, shortName, timeoutMs) {
|
||
const url = `https://${host}/api/v1/repos/plugins/${shortName}/raw/plugin.json?ref=master`;
|
||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||
if (!res.ok) {
|
||
throw new Error(`HTTP ${res.status}`);
|
||
}
|
||
const body = await res.json();
|
||
if (typeof body.version !== 'string') {
|
||
throw new Error('遠端 plugin.json 缺少 version 欄位');
|
||
}
|
||
return body.version;
|
||
}
|
||
|
||
/** 對暫時性失敗(逾時/連線中斷/非 2xx/回應格式錯誤)重試,TLS 憑證驗證全程不跳過。 */
|
||
async function fetchRemoteVersion(host, shortName, timeoutMs) {
|
||
let lastErr;
|
||
for (let attempt = 1; attempt <= REMOTE_FETCH_MAX_ATTEMPTS; attempt++) {
|
||
try {
|
||
return await fetchRemoteVersionOnce(host, shortName, timeoutMs);
|
||
} catch (err) {
|
||
lastErr = err;
|
||
if (attempt < REMOTE_FETCH_MAX_ATTEMPTS) {
|
||
log(
|
||
'WRN',
|
||
`遠端版本查詢第 ${attempt} 次失敗(${String(err.message)}),${REMOTE_FETCH_RETRY_DELAY_MS}ms 後重試…`,
|
||
'版本檢查'
|
||
);
|
||
await sleep(REMOTE_FETCH_RETRY_DELAY_MS);
|
||
}
|
||
}
|
||
}
|
||
throw lastErr;
|
||
}
|
||
|
||
function blockMessage(pluginName, remoteVersion, loadedVersion) {
|
||
const remoteText = remoteVersion || '查無法取得遠端版本';
|
||
return (
|
||
`${pluginName} 遠端發佈版本為 ${remoteText},當前實際載入版本為 ${loadedVersion}。\n` +
|
||
'請執行 /jsc-shared:plugins-install 更新後重新開啟工作階段。'
|
||
);
|
||
}
|
||
|
||
/** 嘗試把 stdin 當 hook event JSON 讀(比照 persona/hooks/_hook.mjs 的 readEvent());讀不到回傳 null。 */
|
||
function readHookEvent() {
|
||
try {
|
||
const raw = fs.readFileSync(0, 'utf8');
|
||
if (!raw.trim()) return null;
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** hook 模式的回應:exit code 固定 0,是否阻擋由 stdout JSON 的 permissionDecision 決定。 */
|
||
function respondHook(decision, reason) {
|
||
process.stdout.write(
|
||
`${JSON.stringify({
|
||
hookSpecificOutput: {
|
||
hookEventName: 'PreToolUse',
|
||
permissionDecision: decision,
|
||
...(reason ? { permissionDecisionReason: reason } : {}),
|
||
},
|
||
})}\n`
|
||
);
|
||
process.exit(0);
|
||
}
|
||
|
||
async function main() {
|
||
const args = parseArgs(process.argv.slice(2));
|
||
const hookEvent = readHookEvent();
|
||
const isHookMode = hookEvent !== null;
|
||
|
||
function fail(message) {
|
||
log('ERR', redact(message), '版本檢查');
|
||
if (isHookMode) respondHook('deny', redact(message));
|
||
process.exit(2);
|
||
}
|
||
|
||
const pluginRoot = resolvePluginRoot(args);
|
||
if (!pluginRoot) {
|
||
fail('無法判定本地 plugin 根目錄(未帶 --plugin-root 且 $CLAUDE_PLUGIN_ROOT 未設定),fail-closed 阻擋。');
|
||
return;
|
||
}
|
||
|
||
const pluginJsonPath = path.join(pluginRoot, 'plugin.json');
|
||
let localMeta;
|
||
try {
|
||
localMeta = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8'));
|
||
} catch (err) {
|
||
fail(`讀取本地 plugin.json 失敗(${String(err.message)}),fail-closed 阻擋。`);
|
||
return;
|
||
}
|
||
|
||
const loadedVersion = localMeta.version;
|
||
const shortName = shortNameFromPluginName(localMeta.name);
|
||
const host = resolveHost(args);
|
||
|
||
let remoteVersion = null;
|
||
try {
|
||
remoteVersion = await fetchRemoteVersion(host, shortName, args.timeoutMs);
|
||
} catch (err) {
|
||
fail(`${blockMessage(localMeta.name, null, loadedVersion)}(遠端查詢失敗:${String(err.message)})`);
|
||
return;
|
||
}
|
||
|
||
if (remoteVersion !== loadedVersion) {
|
||
fail(blockMessage(localMeta.name, remoteVersion, loadedVersion));
|
||
return;
|
||
}
|
||
|
||
log('INF', `${localMeta.name} 版本相符(${loadedVersion}),放行。`, '版本檢查');
|
||
if (isHookMode) respondHook('allow');
|
||
process.exit(0);
|
||
}
|
||
|
||
main();
|