#!/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 ] [--host ] [--timeout-ms ] * * --plugin-root 本地 plugin 根目錄(含 plugin.json)。省略時使用 $CLAUDE_PLUGIN_ROOT; * 兩者皆無則視為無法判定,fail-closed。 * --host Gitea 主機,省略時依序取 $GITEA_HOST,再退回 gitea.jsc.idv.tw。 * --timeout-ms 遠端查詢逾時毫秒數,預設 5000。 * * 兩種呼叫模式(自動偵測,不需額外旗標): * 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/17 14:25:00 * 相依: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 必須帶上此旗標。 * 機密:不涉及 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; } function parseArgs(argv) { const args = { pluginRoot: null, host: null, timeoutMs: 5000 }; 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 fetchRemoteVersion(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; } 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();