From 195dbe5a8360ee9b9ceb4528cfdc766d3dd2f29f Mon Sep 17 00:00:00 2001 From: SAN Date: Wed, 5 Aug 2026 01:25:59 +0800 Subject: [PATCH 1/5] feat(plugin-protocol): scope secret injection by endpoint Co-Authored-By: Claude Signed-off-by: SAN --- .../src/__tests__/manifest.test.ts | 46 +++++++++ packages/plugin-protocol/src/manifest.ts | 95 +++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/packages/plugin-protocol/src/__tests__/manifest.test.ts b/packages/plugin-protocol/src/__tests__/manifest.test.ts index bbf48dd..22037cb 100644 --- a/packages/plugin-protocol/src/__tests__/manifest.test.ts +++ b/packages/plugin-protocol/src/__tests__/manifest.test.ts @@ -77,6 +77,52 @@ describe('Ghost manifest contract', () => { expect(baseline.ok && ghostManifestUsesOidcToken(baseline.manifest)).toBe(false); }); + it('accepts and normalizes secret endpoint path/method allowlists', () => { + const result = validateGhostManifest({ + ...validManifest, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com'], + secrets: [{ + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + paths: ['/v1/z', '/v1/convert'], + methods: ['POST', 'GET'], + }, + }], + }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.network?.secrets?.[0]?.inject).toEqual({ + header: 'Authorization', + format: 'Bearer {value}', + paths: ['/v1/convert', '/v1/z'], + methods: ['GET', 'POST'], + }); + }); + + it('rejects ambiguous endpoint paths and unsupported methods', () => { + const validateInject = (inject: Record) => validateGhostManifest({ + ...validManifest, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com'], + secrets: [{ key: 'api_key', label: 'API Key', inject }], + }, + }); + expect(validateInject({ header: 'Authorization', format: 'Bearer {value}', paths: ['/a/../b'] }).ok).toBe(false); + expect(validateInject({ header: 'Authorization', format: 'Bearer {value}', paths: ['/%2fsecret'] }).ok).toBe(false); + expect(validateInject({ header: 'Authorization', format: 'Bearer {value}', methods: ['HEAD'] }).ok).toBe(false); + }); + it('rejects unsafe oidc-token declarations', () => { const base = { ...validManifest, diff --git a/packages/plugin-protocol/src/manifest.ts b/packages/plugin-protocol/src/manifest.ts index e1aab27..7d4438a 100644 --- a/packages/plugin-protocol/src/manifest.ts +++ b/packages/plugin-protocol/src/manifest.ts @@ -338,6 +338,33 @@ function ghostNetworkHostMatches(pattern: string, hostname: string): boolean { return hostname === pattern; } +export const GHOST_SECRET_INJECT_MAX_PATHS = 16; +export const GHOST_SECRET_INJECT_PATH_MAX_CHARS = 1024; +export const GHOST_FETCH_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; +export type GhostFetchMethod = (typeof GHOST_FETCH_METHODS)[number]; + +export function isValidGhostSecretInjectPath(pathname: unknown): pathname is string { + if ( + typeof pathname !== 'string' + || pathname.length === 0 + || pathname.length > GHOST_SECRET_INJECT_PATH_MAX_CHARS + || !pathname.startsWith('/') + || pathname.includes('?') + || pathname.includes('#') + || pathname.includes('\\') + || /[-]/.test(pathname) + || /%(?:2f|5c)/i.test(pathname) + || /%(?![0-9a-f]{2})/i.test(pathname) + ) { + return false; + } + try { + return new URL(pathname, 'https://ghost.invalid').pathname === pathname; + } catch { + return false; + } +} + /** * 凭证注入声明:该凭证以什么形态、进哪些域名的请求头。绑定在 secret 上 * (而非独立 auth 模板)是刻意的——结构上保证"key 只流向它声明的域名", @@ -349,6 +376,10 @@ export interface GhostSecretInjectDecl { * 缺省 = 详单里的全部域名。 */ hosts?: string[]; + /** 精确 URL.pathname 白名单;缺省 = 该 host 下全部路径。 */ + paths?: string[]; + /** HTTP method 白名单;缺省 = 代理 fetch 支持的全部方法。 */ + methods?: GhostFetchMethod[]; /** 注入的请求头名(如 Authorization / X-Subscription-Token)。 */ header: string; /** 头值模板:恰含一个 `{value}` 占位,其余为静态文本(如 `Bearer {value}`)。 */ @@ -1568,6 +1599,68 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { injectHosts.push(ihNorm); } } + let injectPaths: string[] | undefined; + if (inj.paths !== undefined) { + if ( + !Array.isArray(inj.paths) + || inj.paths.length === 0 + || inj.paths.length > GHOST_SECRET_INJECT_MAX_PATHS + ) { + return { + ok: false, + reason: `network.secrets[].inject.paths 必须是 1–${GHOST_SECRET_INJECT_MAX_PATHS} 条精确 pathname 的数组(或省略 = 全部路径)`, + }; + } + injectPaths = []; + for (const path of inj.paths) { + if (!isValidGhostSecretInjectPath(path)) { + return { + ok: false, + reason: `network.secrets[].inject.paths 含非法条目 ${JSON.stringify(path)}`, + }; + } + if (injectPaths.includes(path)) { + return { + ok: false, + reason: `network.secrets[].inject.paths 含重复条目 ${JSON.stringify(path)}`, + }; + } + injectPaths.push(path); + } + injectPaths.sort(); + } + let injectMethods: GhostFetchMethod[] | undefined; + if (inj.methods !== undefined) { + if (!Array.isArray(inj.methods) || inj.methods.length === 0) { + return { + ok: false, + reason: 'network.secrets[].inject.methods 必须是非空数组(或省略 = 全部支持的方法)', + }; + } + injectMethods = []; + for (const method of inj.methods) { + if ( + typeof method !== 'string' + || !(GHOST_FETCH_METHODS as readonly string[]).includes(method) + ) { + return { + ok: false, + reason: `network.secrets[].inject.methods 含未知项 ${JSON.stringify(method)}(可用:${GHOST_FETCH_METHODS.join(' / ')})`, + }; + } + const typed = method as GhostFetchMethod; + if (injectMethods.includes(typed)) { + return { + ok: false, + reason: `network.secrets[].inject.methods 含重复条目 ${JSON.stringify(method)}`, + }; + } + injectMethods.push(typed); + } + injectMethods.sort( + (a, b) => GHOST_FETCH_METHODS.indexOf(a) - GHOST_FETCH_METHODS.indexOf(b), + ); + } if (oidcManaged) { if (inj.header !== 'Authorization' || inj.format !== 'Bearer {value}') { return { @@ -2038,6 +2131,8 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { header: inj.header, format: inj.format, ...(injectHosts !== undefined ? { hosts: injectHosts } : {}), + ...(injectPaths !== undefined ? { paths: injectPaths } : {}), + ...(injectMethods !== undefined ? { methods: injectMethods } : {}), }, ...(exchange !== undefined ? { exchange } : {}), ...(oauth !== undefined ? { oauth } : {}), From 2984e69b1d3cb09c98fe5db0bad5fcbb72e6efb3 Mon Sep 17 00:00:00 2001 From: SAN Date: Wed, 5 Aug 2026 01:56:46 +0800 Subject: [PATCH 2/5] fix(plugin-protocol): satisfy lint/format and cover delivery normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交把控制字符以字面字节写进了正则,导致 `pnpm lint` 报 no-control-regex、`pnpm format:check` 失败,并让 manifest.ts 在 file(1) / grep 等文本工具下被当成二进制。改用 \x 转义序列并按仓库 风格重排,行为不变。 同时补一条 delivery 层用例:endpoint 收窄必须穿过 release manifest 的重新校验与规范化。delivery.ts 是除 validateGhostManifest 之外的 第二个规范化边界,收窄一旦在这里被丢弃,市场安装路径就会退回 "整域可注入"。已用变异验证:去掉规范化输出里的 paths/methods 后, 该用例与 manifest 用例同时失败。 验证: - pnpm --filter @cindy/plugin-protocol test → 36 passed - pnpm --filter @cindy/plugin-protocol build (tsc --noEmit) → 通过 - eslint . → 无告警 - prettier --check . → 全部符合 Co-Authored-By: Claude Signed-off-by: SAN --- .../src/__tests__/delivery.test.ts | 53 ++++++++++++++++++ .../src/__tests__/manifest.test.ts | 54 +++++++++++-------- packages/plugin-protocol/src/manifest.ts | 31 +++++------ 3 files changed, 101 insertions(+), 37 deletions(-) diff --git a/packages/plugin-protocol/src/__tests__/delivery.test.ts b/packages/plugin-protocol/src/__tests__/delivery.test.ts index 879448a..57fdf6b 100644 --- a/packages/plugin-protocol/src/__tests__/delivery.test.ts +++ b/packages/plugin-protocol/src/__tests__/delivery.test.ts @@ -205,6 +205,59 @@ describe('plugin delivery contract', () => { } }); + it('preserves secret endpoint scope through release manifest normalization', () => { + // delivery 层会重新校验并输出规范化 manifest;endpoint 收窄一旦在这里被丢弃, + // 市场安装路径就会退回"整域可注入",属于凭证边界的 fail open。 + const scopedManifest = { + ...validManifest, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com'], + secrets: [ + { + key: 'acme_api_key', + label: 'Acme API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + hosts: ['api.example.com'], + paths: ['/v1/convert'], + methods: ['POST'], + }, + }, + ], + }, + }; + + const response = parseGetPluginResponse({ + schemaVersion: PLUGIN_API_SCHEMA_VERSION, + plugin: { + id: pluginId, + ghostId: scopedManifest.id, + name: scopedManifest.name, + description: null, + author: null, + scope: 'public', + organizationId: null, + defaultInstall: false, + currentRelease: { + id: 'release-scoped', + version: scopedManifest.version, + sha256: 'a'.repeat(64), + sizeBytes: 1024, + publishedAt: '2026-07-19T00:00:00.000Z', + manifest: scopedManifest, + }, + }, + }); + + const inject = response.plugin.currentRelease.manifest?.network?.secrets?.[0]?.inject; + expect(inject?.paths).toEqual(['/v1/convert']); + expect(inject?.methods).toEqual(['POST']); + }); + it('keeps availability separate from default installation', () => { const response = parseListPluginsResponse({ schemaVersion: PLUGIN_API_SCHEMA_VERSION, diff --git a/packages/plugin-protocol/src/__tests__/manifest.test.ts b/packages/plugin-protocol/src/__tests__/manifest.test.ts index 22037cb..7cbdac2 100644 --- a/packages/plugin-protocol/src/__tests__/manifest.test.ts +++ b/packages/plugin-protocol/src/__tests__/manifest.test.ts @@ -85,16 +85,18 @@ describe('Ghost manifest contract', () => { settingsHtml: 'settings.html', network: { hosts: ['api.example.com'], - secrets: [{ - key: 'api_key', - label: 'API Key', - inject: { - header: 'Authorization', - format: 'Bearer {value}', - paths: ['/v1/z', '/v1/convert'], - methods: ['POST', 'GET'], + secrets: [ + { + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + paths: ['/v1/z', '/v1/convert'], + methods: ['POST', 'GET'], + }, }, - }], + ], }, }); expect(result.ok).toBe(true); @@ -108,19 +110,27 @@ describe('Ghost manifest contract', () => { }); it('rejects ambiguous endpoint paths and unsupported methods', () => { - const validateInject = (inject: Record) => validateGhostManifest({ - ...validManifest, - tools: undefined, - slots: ['network'], - settingsHtml: 'settings.html', - network: { - hosts: ['api.example.com'], - secrets: [{ key: 'api_key', label: 'API Key', inject }], - }, - }); - expect(validateInject({ header: 'Authorization', format: 'Bearer {value}', paths: ['/a/../b'] }).ok).toBe(false); - expect(validateInject({ header: 'Authorization', format: 'Bearer {value}', paths: ['/%2fsecret'] }).ok).toBe(false); - expect(validateInject({ header: 'Authorization', format: 'Bearer {value}', methods: ['HEAD'] }).ok).toBe(false); + const validateInject = (inject: Record) => + validateGhostManifest({ + ...validManifest, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com'], + secrets: [{ key: 'api_key', label: 'API Key', inject }], + }, + }); + expect( + validateInject({ header: 'Authorization', format: 'Bearer {value}', paths: ['/a/../b'] }).ok, + ).toBe(false); + expect( + validateInject({ header: 'Authorization', format: 'Bearer {value}', paths: ['/%2fsecret'] }) + .ok, + ).toBe(false); + expect( + validateInject({ header: 'Authorization', format: 'Bearer {value}', methods: ['HEAD'] }).ok, + ).toBe(false); }); it('rejects unsafe oidc-token declarations', () => { diff --git a/packages/plugin-protocol/src/manifest.ts b/packages/plugin-protocol/src/manifest.ts index 7d4438a..615747b 100644 --- a/packages/plugin-protocol/src/manifest.ts +++ b/packages/plugin-protocol/src/manifest.ts @@ -345,16 +345,17 @@ export type GhostFetchMethod = (typeof GHOST_FETCH_METHODS)[number]; export function isValidGhostSecretInjectPath(pathname: unknown): pathname is string { if ( - typeof pathname !== 'string' - || pathname.length === 0 - || pathname.length > GHOST_SECRET_INJECT_PATH_MAX_CHARS - || !pathname.startsWith('/') - || pathname.includes('?') - || pathname.includes('#') - || pathname.includes('\\') - || /[-]/.test(pathname) - || /%(?:2f|5c)/i.test(pathname) - || /%(?![0-9a-f]{2})/i.test(pathname) + typeof pathname !== 'string' || + pathname.length === 0 || + pathname.length > GHOST_SECRET_INJECT_PATH_MAX_CHARS || + !pathname.startsWith('/') || + pathname.includes('?') || + pathname.includes('#') || + pathname.includes('\\') || + // eslint-disable-next-line no-control-regex -- 控制字符是显式清洗目标 + /[\x00-\x1f\x7f]/.test(pathname) || + /%(?:2f|5c)/i.test(pathname) || + /%(?![0-9a-f]{2})/i.test(pathname) ) { return false; } @@ -1602,9 +1603,9 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { let injectPaths: string[] | undefined; if (inj.paths !== undefined) { if ( - !Array.isArray(inj.paths) - || inj.paths.length === 0 - || inj.paths.length > GHOST_SECRET_INJECT_MAX_PATHS + !Array.isArray(inj.paths) || + inj.paths.length === 0 || + inj.paths.length > GHOST_SECRET_INJECT_MAX_PATHS ) { return { ok: false, @@ -1640,8 +1641,8 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { injectMethods = []; for (const method of inj.methods) { if ( - typeof method !== 'string' - || !(GHOST_FETCH_METHODS as readonly string[]).includes(method) + typeof method !== 'string' || + !(GHOST_FETCH_METHODS as readonly string[]).includes(method) ) { return { ok: false, From 845b625e67414b7df0da54e00ad211098956e9f7 Mon Sep 17 00:00:00 2001 From: SAN Date: Thu, 6 Aug 2026 19:28:33 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(plugin-protocol):=20=E5=BD=92=E4=B8=80?= =?UTF-8?q?=E5=8C=96=20inject.hosts=20=E9=A1=BA=E5=BA=8F,=E6=9D=83?= =?UTF-8?q?=E9=99=90=20detail/diff=20=E7=A8=B3=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与 paths / methods 同款排序归一化:host 顺序的无意义变动不再引起 权限展示与批准 diff 抖动。 Co-Authored-By: Claude Signed-off-by: SAN --- .../src/__tests__/manifest.test.ts | 29 +++++++++++++++++++ packages/plugin-protocol/src/manifest.ts | 3 ++ 2 files changed, 32 insertions(+) diff --git a/packages/plugin-protocol/src/__tests__/manifest.test.ts b/packages/plugin-protocol/src/__tests__/manifest.test.ts index 7cbdac2..c023739 100644 --- a/packages/plugin-protocol/src/__tests__/manifest.test.ts +++ b/packages/plugin-protocol/src/__tests__/manifest.test.ts @@ -109,6 +109,35 @@ describe('Ghost manifest contract', () => { }); }); + it('normalizes inject.hosts order deterministically (stable permission detail/diff)', () => { + const result = validateGhostManifest({ + ...validManifest, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com', 'cdn.example.com'], + secrets: [ + { + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + hosts: ['cdn.example.com', 'api.example.com'], + }, + }, + ], + }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.network?.secrets?.[0]?.inject.hosts).toEqual([ + 'api.example.com', + 'cdn.example.com', + ]); + }); + it('rejects ambiguous endpoint paths and unsupported methods', () => { const validateInject = (inject: Record) => validateGhostManifest({ diff --git a/packages/plugin-protocol/src/manifest.ts b/packages/plugin-protocol/src/manifest.ts index 615747b..f4688ca 100644 --- a/packages/plugin-protocol/src/manifest.ts +++ b/packages/plugin-protocol/src/manifest.ts @@ -1599,6 +1599,9 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { } injectHosts.push(ihNorm); } + // 与 paths / methods 同款排序归一化:host 顺序的无意义变动不该引起 + // 权限 detail / diff 抖动。 + injectHosts.sort(); } let injectPaths: string[] | undefined; if (inj.paths !== undefined) { From b860a6a579252adb4ea5bc24d656844cbcb74218 Mon Sep 17 00:00:00 2001 From: SAN Date: Thu, 6 Aug 2026 20:42:19 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(plugin-protocol):=20inject.hosts=20?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E5=8F=AA=E5=AF=B9=E6=96=B0=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=E7=94=9F=E6=95=88,=E4=BF=9D=E6=8A=A4?= =?UTF-8?q?=E6=97=A7=E6=B8=85=E5=8D=95=20digest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifestDigest 按数组原始顺序计算;对旧 host-only 清单排序会让已装 插件的账本摘要永久失配,触发市场所有权检查与 OIDC 签发拒绝。 改为仅当同一凭证声明 inject.paths / inject.methods 时排序 hosts, 此时权限 detail 才需要稳定输出;旧清单归一化结果与升级前逐字节一致。 Co-Authored-By: Claude Signed-off-by: SAN --- .../src/__tests__/manifest.test.ts | 34 ++++++++++++++++++- packages/plugin-protocol/src/manifest.ts | 9 +++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/plugin-protocol/src/__tests__/manifest.test.ts b/packages/plugin-protocol/src/__tests__/manifest.test.ts index c023739..20e3692 100644 --- a/packages/plugin-protocol/src/__tests__/manifest.test.ts +++ b/packages/plugin-protocol/src/__tests__/manifest.test.ts @@ -109,7 +109,9 @@ describe('Ghost manifest contract', () => { }); }); - it('normalizes inject.hosts order deterministically (stable permission detail/diff)', () => { + it('keeps legacy host-only inject.hosts order unchanged (manifestDigest compatibility)', () => { + // 旧清单的归一化输出必须与升级前逐字节一致:manifestDigest 按数组原始顺序 + // 计算,排序会让已装插件的账本摘要永久失配。 const result = validateGhostManifest({ ...validManifest, tools: undefined, @@ -132,6 +134,36 @@ describe('Ghost manifest contract', () => { }); expect(result.ok).toBe(true); if (!result.ok) return; + expect(result.manifest.network?.secrets?.[0]?.inject.hosts).toEqual([ + 'cdn.example.com', + 'api.example.com', + ]); + }); + + it('sorts inject.hosts once endpoint fields are declared (stable permission detail/diff)', () => { + const result = validateGhostManifest({ + ...validManifest, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com', 'cdn.example.com'], + secrets: [ + { + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + hosts: ['cdn.example.com', 'api.example.com'], + paths: ['/v1/convert'], + }, + }, + ], + }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; expect(result.manifest.network?.secrets?.[0]?.inject.hosts).toEqual([ 'api.example.com', 'cdn.example.com', diff --git a/packages/plugin-protocol/src/manifest.ts b/packages/plugin-protocol/src/manifest.ts index f4688ca..de049bf 100644 --- a/packages/plugin-protocol/src/manifest.ts +++ b/packages/plugin-protocol/src/manifest.ts @@ -1599,9 +1599,6 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { } injectHosts.push(ihNorm); } - // 与 paths / methods 同款排序归一化:host 顺序的无意义变动不该引起 - // 权限 detail / diff 抖动。 - injectHosts.sort(); } let injectPaths: string[] | undefined; if (inj.paths !== undefined) { @@ -1665,6 +1662,12 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { (a, b) => GHOST_FETCH_METHODS.indexOf(a) - GHOST_FETCH_METHODS.indexOf(b), ); } + // 排序归一化只对声明了新字段(inject.paths / inject.methods)的凭证生效: + // 旧 host-only 清单的归一化输出必须与升级前逐字节一致(manifestDigest 按 + // 数组原始顺序计算,排序会让已装插件的账本摘要永久失配)。 + if (injectHosts !== undefined && (injectPaths !== undefined || injectMethods !== undefined)) { + injectHosts.sort(); + } if (oidcManaged) { if (inj.header !== 'Authorization' || inj.format !== 'Bearer {value}') { return { From 6d1a4f2b75b3c400b5e0af18a5c5e94e52c49ad6 Mon Sep 17 00:00:00 2001 From: SAN Date: Sat, 8 Aug 2026 10:45:40 +0800 Subject: [PATCH 5/5] =?UTF-8?q?feat(plugin-protocol):=20endpoint-scoped=20?= =?UTF-8?q?secret=20=E6=B3=A8=E5=85=A5=E8=A6=81=E6=B1=82=20schemaVersion?= =?UTF-8?q?=203(mixed-version=20fail-closed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v2 清单声明 inject.paths/methods 直接拒装:旧客户端不识别这两个字段, 放行会让收窄静默退化为整域注入(fail-open)。声明新字段即升级版本, 旧客户端对 v3 整包拒装(schemaVersion 严格相等检查),形成 fail-closed 边界。normalize 输出保留输入版本,v3 清单 manifestDigest 与打包时一致。 Co-Authored-By: Claude Signed-off-by: SAN --- .../src/__tests__/delivery.test.ts | 6 +- .../src/__tests__/manifest.test.ts | 67 ++++++++++++++++++- packages/plugin-protocol/src/manifest.ts | 43 ++++++++++-- 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/packages/plugin-protocol/src/__tests__/delivery.test.ts b/packages/plugin-protocol/src/__tests__/delivery.test.ts index 57fdf6b..29c8f65 100644 --- a/packages/plugin-protocol/src/__tests__/delivery.test.ts +++ b/packages/plugin-protocol/src/__tests__/delivery.test.ts @@ -6,7 +6,10 @@ import { parsePluginDownloadResponse, PluginProtocolError, } from '../delivery.js'; -import { GHOST_MANIFEST_SCHEMA_VERSION } from '../manifest.js'; +import { + GHOST_MANIFEST_SCHEMA_VERSION, + GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, +} from '../manifest.js'; const validManifest = { schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION, @@ -210,6 +213,7 @@ describe('plugin delivery contract', () => { // 市场安装路径就会退回"整域可注入",属于凭证边界的 fail open。 const scopedManifest = { ...validManifest, + schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, tools: undefined, slots: ['network'], settingsHtml: 'settings.html', diff --git a/packages/plugin-protocol/src/__tests__/manifest.test.ts b/packages/plugin-protocol/src/__tests__/manifest.test.ts index 3115208..20f97af 100644 --- a/packages/plugin-protocol/src/__tests__/manifest.test.ts +++ b/packages/plugin-protocol/src/__tests__/manifest.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { GHOST_MANIFEST_SUMMARY_MAX_CHARS, GHOST_MANIFEST_SCHEMA_VERSION, + GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, GHOST_OAUTH_SCOPES_MAX, compareCindyVersions, ghostManifestUsesOidcToken, @@ -133,9 +134,10 @@ describe('Ghost manifest contract', () => { expect(baseline.ok && ghostManifestUsesOidcToken(baseline.manifest)).toBe(false); }); - it('accepts and normalizes secret endpoint path/method allowlists', () => { + it('accepts and normalizes secret endpoint path/method allowlists (schema v3)', () => { const result = validateGhostManifest({ ...validManifest, + schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, tools: undefined, slots: ['network'], settingsHtml: 'settings.html', @@ -157,6 +159,7 @@ describe('Ghost manifest contract', () => { }); expect(result.ok).toBe(true); if (!result.ok) return; + expect(result.manifest.schemaVersion).toBe(GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE); expect(result.manifest.network?.secrets?.[0]?.inject).toEqual({ header: 'Authorization', format: 'Bearer {value}', @@ -165,6 +168,66 @@ describe('Ghost manifest contract', () => { }); }); + it('rejects endpoint-scoped inject on schema v2 (mixed-version fail-closed)', () => { + // 旧客户端不识别 paths/methods,会静默退化为整域注入(fail-open)。因此声明 + // 新字段的清单必须升级到 v3——v2 上声明直接拒装,老客户端遇到 v3 也整包拒装。 + for (const extra of [ + { paths: ['/v1/convert'] }, + { methods: ['POST'] }, + { paths: ['/v1/convert'], methods: ['POST'] }, + ]) { + const result = validateGhostManifest({ + ...validManifest, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com'], + secrets: [ + { + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + ...extra, + }, + }, + ], + }, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toContain('schemaVersion'); + } + }); + + it('accepts host-only inject on schema v3 (backward compatible)', () => { + const result = validateGhostManifest({ + ...validManifest, + schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, + tools: undefined, + slots: ['network'], + settingsHtml: 'settings.html', + network: { + hosts: ['api.example.com'], + secrets: [ + { + key: 'api_key', + label: 'API Key', + inject: { + header: 'Authorization', + format: 'Bearer {value}', + hosts: ['api.example.com'], + }, + }, + ], + }, + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.manifest.network?.secrets?.[0]?.inject.hosts).toEqual(['api.example.com']); + }); + it('keeps legacy host-only inject.hosts order unchanged (manifestDigest compatibility)', () => { // 旧清单的归一化输出必须与升级前逐字节一致:manifestDigest 按数组原始顺序 // 计算,排序会让已装插件的账本摘要永久失配。 @@ -199,6 +262,7 @@ describe('Ghost manifest contract', () => { it('sorts inject.hosts once endpoint fields are declared (stable permission detail/diff)', () => { const result = validateGhostManifest({ ...validManifest, + schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, tools: undefined, slots: ['network'], settingsHtml: 'settings.html', @@ -230,6 +294,7 @@ describe('Ghost manifest contract', () => { const validateInject = (inject: Record) => validateGhostManifest({ ...validManifest, + schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE, tools: undefined, slots: ['network'], settingsHtml: 'settings.html', diff --git a/packages/plugin-protocol/src/manifest.ts b/packages/plugin-protocol/src/manifest.ts index 83a0acc..94ddad0 100644 --- a/packages/plugin-protocol/src/manifest.ts +++ b/packages/plugin-protocol/src/manifest.ts @@ -7,6 +7,18 @@ export const CINDY_FILE_EXT = '.cindy'; /** ghost.json 格式版本;与 Plugin HTTP API envelope 版本独立演进。 */ export const GHOST_MANIFEST_SCHEMA_VERSION = 2 as const; +/** + * 声明 endpoint-scoped secret 注入(inject.paths / inject.methods)所需的 schema 版本。 + * 旧客户端只认 GHOST_MANIFEST_SCHEMA_VERSION,对更高的版本整包拒装——这是 + * mixed-version fail-closed 边界:使用新安全字段的插件必须声明该版本,在旧客户端 + * 上被拒绝,而不是被静默降级为整域注入(权限收窄退化为放开)。 + */ +export const GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE = 3 as const; + +/** ghost.json 当前受理的全部 schema 版本。 */ +export type GhostManifestSchemaVersion = + typeof GHOST_MANIFEST_SCHEMA_VERSION | typeof GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE; + /** ghost.json 的 description / whenToUse 字符上限。 */ export const GHOST_MANIFEST_SUMMARY_MAX_CHARS = 300; @@ -786,8 +798,8 @@ export interface GhostSkillNeeds { /** ghost.json 清单(不变量由 validateGhostManifest 保证)。 */ export interface GhostManifest { - /** 清单格式版本,恒 2(v1 声明型已于 2026-07-12 移除,无存量不留兼容)。 */ - schemaVersion: typeof GHOST_MANIFEST_SCHEMA_VERSION; + /** 清单格式版本:2 = 基线(v1 声明型已于 2026-07-12 移除);3 = endpoint-scoped 凭证注入。 */ + schemaVersion: GhostManifestSchemaVersion; /** 唯一标识,同时是安装目录名与 panelKind 后缀。 */ id: string; /** 展示名。 */ @@ -1052,10 +1064,13 @@ function isPlainObject(v: unknown): v is Record { export function validateGhostManifest(raw: unknown): ManifestValidation { if (!isPlainObject(raw)) return { ok: false, reason: '清单不是对象' }; - if (raw.schemaVersion !== GHOST_MANIFEST_SCHEMA_VERSION) { + if ( + raw.schemaVersion !== GHOST_MANIFEST_SCHEMA_VERSION && + raw.schemaVersion !== GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE + ) { return { ok: false, - reason: `schemaVersion 必须是 ${GHOST_MANIFEST_SCHEMA_VERSION},得到 ${JSON.stringify(raw.schemaVersion)}(v1 声明型已于 2026-07-12 移除)`, + reason: `schemaVersion 必须是 ${GHOST_MANIFEST_SCHEMA_VERSION} 或 ${GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE},得到 ${JSON.stringify(raw.schemaVersion)}(v1 声明型已于 2026-07-12 移除)`, }; } if (!isValidGhostId(raw.id)) { @@ -1796,6 +1811,18 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { injectHosts.push(ihNorm); } } + // endpoint-scoped 注入(paths / methods)必须声明 schemaVersion 3: + // 旧客户端不识别这两个字段,若放行会让收窄静默退化为整域注入(fail-open)。 + // 声明新字段即升级版本,旧客户端对 v3 整包拒装——mixed-version fail-closed。 + if ( + (inj.paths !== undefined || inj.methods !== undefined) && + raw.schemaVersion !== GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE + ) { + return { + ok: false, + reason: `network.secrets[].inject 声明了 paths/methods,必须使用 schemaVersion ${GHOST_MANIFEST_SCHEMA_VERSION_ENDPOINT_SCOPE}(旧版本客户端会忽略这些字段并退化为整域注入,故强制升级清单版本)`, + }; + } let injectPaths: string[] | undefined; if (inj.paths !== undefined) { if ( @@ -1861,7 +1888,10 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { // 排序归一化只对声明了新字段(inject.paths / inject.methods)的凭证生效: // 旧 host-only 清单的归一化输出必须与升级前逐字节一致(manifestDigest 按 // 数组原始顺序计算,排序会让已装插件的账本摘要永久失配)。 - if (injectHosts !== undefined && (injectPaths !== undefined || injectMethods !== undefined)) { + if ( + injectHosts !== undefined && + (injectPaths !== undefined || injectMethods !== undefined) + ) { injectHosts.sort(); } if (oidcManaged) { @@ -2954,7 +2984,8 @@ export function validateGhostManifest(raw: unknown): ManifestValidation { return { ok: true, manifest: { - schemaVersion: GHOST_MANIFEST_SCHEMA_VERSION, + // 保留输入版本(v3 清单不得被降级回 v2,否则 manifestDigest 与打包时失配)。 + schemaVersion: raw.schemaVersion as GhostManifestSchemaVersion, id: raw.id, name: raw.name, version: raw.version,