feat(scum): gate live data on schema evidence

This commit is contained in:
npc0-hue
2026-08-11 13:28:36 +08:00
parent e5d26e5420
commit a58da47c60
18 changed files with 1265 additions and 3 deletions
@@ -70,6 +70,7 @@
"remote.rsync.write",
"remote.run.files.read",
"remote.run.files.write",
"remote.run.db.sqlite.probe",
"remote.run.db.sqlite.query",
"remote.run.process.start",
"remote.run.process.stop",
@@ -95,6 +96,7 @@
"runCapabilities": [
"remote.run.files.read",
"remote.run.files.write",
"remote.run.db.sqlite.probe",
"remote.run.db.sqlite.query",
"remote.run.process.start",
"remote.run.process.stop",
@@ -556,6 +558,36 @@
"requestTimeoutSeconds": 15
}
},
"scumLiveData": {
"schemaVersion": "1",
"probe": {
"capability": "remote.run.db.sqlite.probe",
"targetKey": "scum-database",
"bounds": {
"maxObjects": 256,
"maxColumnsPerObject": 128,
"maxIndexesPerObject": 64,
"maxForeignKeys": 64,
"maxCardinalityReads": 64,
"maxSampleRows": 3,
"timeoutMs": 5000,
"maxResultBytes": 524288
}
},
"capabilityGates": [
{ "capability": "schema-probe", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前绑定的 Run 证明通用只读 schema probe 可用。" },
{ "capability": "players.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务 schema evidence 与版本化 adapter 匹配。" },
{ "capability": "player-details.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务玩家详情 join 与字段含义验证。" },
{ "capability": "squads.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务队伍表与 rank/leader 含义验证。" },
{ "capability": "squad-members.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务队伍成员 join 唯一性验证。" },
{ "capability": "vehicles.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务载具 identity、ownership 和坐标验证。" },
{ "capability": "flags.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务旗子 ownership 与 territory 关系验证。" },
{ "capability": "positions.read", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待当前服务坐标范围、cadence 和 map transform 验证。" },
{ "capability": "profile-xml.write", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待真实 XML 来源、字段名、backup 和离线安全证据验证。" },
{ "capability": "economy-command.write", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待 Fame/currency command 与 readback confirmation 验证。" },
{ "capability": "gift-command.write", "gate": "disabled", "adapterVersion": "scum-live-data-v0", "evidenceStatus": "missing", "safeReason": "等待 gift item catalog、transport 和 conclusive receipt 验证。" }
]
},
"permissions": [
"server.create",
"server.read",
@@ -1069,6 +1101,7 @@
"kind": "sqlite",
"targetKey": "scum-database",
"capabilities": [
"remote.run.db.sqlite.probe",
"remote.run.db.sqlite.query",
"remote.run.protected.sql"
]
@@ -50,6 +50,7 @@
"gameClientBridge": {
"$ref": "#/$defs/gameClientBridgeManifest"
},
"scumLiveData": { "$ref": "#/$defs/scumLiveDataManifest" },
"mapTrajectories": { "$ref": "#/$defs/mapTrajectoryDeclaration" },
"capabilities": {
"type": "array",
@@ -287,6 +288,48 @@
"companion": { "$ref": "#/$defs/gameClientBridgeCompanion" }
}
},
"scumLiveDataManifest": {
"type": "object",
"required": ["schemaVersion", "probe", "capabilityGates"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "1" },
"probe": { "$ref": "#/$defs/scumSchemaProbeDeclaration" },
"capabilityGates": { "type": "array", "items": { "$ref": "#/$defs/scumLiveDataCapabilityGate" }, "uniqueItems": true, "minItems": 1, "maxItems": 32 }
}
},
"scumSchemaProbeDeclaration": {
"type": "object",
"required": ["capability", "targetKey", "bounds"],
"additionalProperties": false,
"properties": {
"capability": { "const": "remote.run.db.sqlite.probe" },
"targetKey": { "$ref": "#/$defs/logicalKey" },
"bounds": { "$ref": "#/$defs/scumSchemaProbeBounds" }
}
},
"scumSchemaProbeBounds": {
"type": "object",
"required": ["maxObjects", "maxColumnsPerObject", "maxIndexesPerObject", "maxForeignKeys", "maxCardinalityReads", "maxSampleRows", "timeoutMs", "maxResultBytes"],
"additionalProperties": false,
"properties": {
"maxObjects": { "type": "integer", "minimum": 1, "maximum": 512 }, "maxColumnsPerObject": { "type": "integer", "minimum": 1, "maximum": 256 }, "maxIndexesPerObject": { "type": "integer", "minimum": 0, "maximum": 128 }, "maxForeignKeys": { "type": "integer", "minimum": 0, "maximum": 128 }, "maxCardinalityReads": { "type": "integer", "minimum": 0, "maximum": 512 }, "maxSampleRows": { "type": "integer", "minimum": 0, "maximum": 3 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 10000 }, "maxResultBytes": { "type": "integer", "minimum": 1, "maximum": 1048576 }
}
},
"scumLiveDataCapabilityGate": {
"type": "object",
"required": ["capability", "gate", "adapterVersion", "evidenceStatus", "safeReason"],
"additionalProperties": false,
"properties": {
"capability": { "enum": ["schema-probe", "players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"] },
"gate": { "enum": ["disabled", "enabled"] },
"adapterVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$" },
"requiredSchemaFingerprint": { "type": "string", "pattern": "^(sha256:)?[a-fA-F0-9]{16,128}$" },
"requiredAssetDigests": { "type": "array", "items": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" }, "uniqueItems": true, "maxItems": 16 },
"evidenceStatus": { "enum": ["missing", "compatible", "incompatible", "failed"] },
"safeReason": { "type": "string", "minLength": 1, "maxLength": 240 }
}
},
"gameClientBridgeCompanion": {
"type": "object",
"required": ["profileKey", "configTemplateKey", "configSchemaRef", "configFormat", "platformBaseUrlSource", "registrationProof", "proofMaterialSource", "proofMaterialEnv", "sessionMode", "tlsPolicy", "heartbeatIntervalSeconds", "commandPollIntervalSeconds", "requestTimeoutSeconds"],
@@ -463,6 +506,7 @@
"remote.run.process.start",
"remote.run.process.stop",
"remote.run.db.mysql.query",
"remote.run.db.sqlite.probe",
"remote.run.db.sqlite.query",
"remote.run.logs.transfer",
"remote.run.rcon.command",
@@ -0,0 +1,46 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://browser.local/schemas/scum-live-data.schema.json",
"title": "SCUMLiveDataManifestDeclaration",
"type": "object",
"required": ["schemaVersion", "probe", "capabilityGates"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "1" },
"probe": {
"type": "object",
"required": ["capability", "targetKey", "bounds"],
"additionalProperties": false,
"properties": {
"capability": { "const": "remote.run.db.sqlite.probe" },
"targetKey": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._/-]*$", "maxLength": 120 },
"bounds": { "$ref": "#/$defs/probeBounds" }
}
},
"capabilityGates": { "type": "array", "items": { "$ref": "#/$defs/capabilityGate" }, "minItems": 1, "maxItems": 32 }
},
"$defs": {
"probeBounds": {
"type": "object",
"required": ["maxObjects", "maxColumnsPerObject", "maxIndexesPerObject", "maxForeignKeys", "maxCardinalityReads", "maxSampleRows", "timeoutMs", "maxResultBytes"],
"additionalProperties": false,
"properties": {
"maxObjects": { "type": "integer", "minimum": 1, "maximum": 512 }, "maxColumnsPerObject": { "type": "integer", "minimum": 1, "maximum": 256 }, "maxIndexesPerObject": { "type": "integer", "minimum": 0, "maximum": 128 }, "maxForeignKeys": { "type": "integer", "minimum": 0, "maximum": 128 }, "maxCardinalityReads": { "type": "integer", "minimum": 0, "maximum": 512 }, "maxSampleRows": { "type": "integer", "minimum": 0, "maximum": 3 }, "timeoutMs": { "type": "integer", "minimum": 1, "maximum": 10000 }, "maxResultBytes": { "type": "integer", "minimum": 1, "maximum": 1048576 }
}
},
"capabilityGate": {
"type": "object",
"required": ["capability", "gate", "adapterVersion", "evidenceStatus", "safeReason"],
"additionalProperties": false,
"properties": {
"capability": { "enum": ["schema-probe", "players.read", "player-details.read", "squads.read", "squad-members.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"] },
"gate": { "enum": ["disabled", "enabled"] },
"adapterVersion": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,79}$" },
"requiredSchemaFingerprint": { "type": "string", "pattern": "^(sha256:)?[a-fA-F0-9]{16,128}$" },
"requiredAssetDigests": { "type": "array", "items": { "type": "string", "pattern": "^sha256:[a-fA-F0-9]{64}$" }, "uniqueItems": true, "maxItems": 16 },
"evidenceStatus": { "enum": ["missing", "compatible", "incompatible", "failed"] },
"safeReason": { "type": "string", "minLength": 1, "maxLength": 240 }
}
}
}
}
+48
View File
@@ -1054,6 +1054,53 @@ export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
return errors;
}
export function validateSCUMLiveDataManifest(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null) return [];
type Gate = { capability?: string; gate?: string; adapterVersion?: string; requiredSchemaFingerprint?: string; requiredAssetDigests?: string[]; evidenceStatus?: string; safeReason?: string };
type Probe = { capability?: string; targetKey?: string; bounds?: { maxSampleRows?: number; timeoutMs?: number; maxResultBytes?: number } };
type Transport = { key?: string; kind?: string; targetKey?: string; capabilities?: string[] };
const declaration = manifest as { id?: string; capabilities?: string[]; remoteAccess?: { runCapabilities?: string[] }; runtimeProfiles?: { transportProfiles?: Transport[] }; scumLiveData?: { schemaVersion?: string; probe?: Probe; capabilityGates?: Gate[] } };
const liveData = declaration.scumLiveData;
if (!liveData) return [];
const errors: string[] = [];
const declaredCapabilities = new Set(declaration.capabilities ?? []);
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
const probe = liveData.probe;
const location = "manifest.scumLiveData";
if (liveData.schemaVersion !== "1") errors.push(`${location}.schemaVersion: must be 1`);
if (probe?.capability !== "remote.run.db.sqlite.probe") errors.push(`${location}.probe.capability: must be remote.run.db.sqlite.probe`);
if (!declaredCapabilities.has("remote.run.db.sqlite.probe") || !remoteCapabilities.has("remote.run.db.sqlite.probe")) errors.push(`${location}.probe: plugin and remoteAccess must declare remote.run.db.sqlite.probe`);
const transport = (declaration.runtimeProfiles?.transportProfiles ?? []).find((candidate) => candidate.targetKey === probe?.targetKey || candidate.key === probe?.targetKey);
if (!transport) {
errors.push(`${location}.probe.targetKey: must reference a declared runtime transport profile or target`);
} else {
if (transport.kind !== "sqlite") errors.push(`${location}.probe.targetKey: schema probe requires a sqlite transport profile`);
if (!transport.capabilities?.includes("remote.run.db.sqlite.probe")) errors.push(`${location}.probe.targetKey: sqlite transport must declare remote.run.db.sqlite.probe`);
}
if ((probe?.bounds?.maxSampleRows ?? 0) > 3) errors.push(`${location}.probe.bounds.maxSampleRows: redacted samples are limited to 3`);
if ((probe?.bounds?.timeoutMs ?? 0) > 10000) errors.push(`${location}.probe.bounds.timeoutMs: must be bounded to 10 seconds or less`);
if ((probe?.bounds?.maxResultBytes ?? 0) > 1048576) errors.push(`${location}.probe.bounds.maxResultBytes: must be bounded to 1 MiB or less`);
const gates = liveData.capabilityGates ?? [];
const seen = new Set<string>();
for (const [index, gate] of gates.entries()) {
const gateLocation = `${location}.capabilityGates[${index}]`;
const capability = gate.capability ?? "";
if (seen.has(capability)) errors.push(`${gateLocation}.capability: duplicate gate ${capability}`);
seen.add(capability);
const reasonErrors = unsafeStringReasons(gate.safeReason ?? "");
errors.push(...reasonErrors.map((reason) => `${gateLocation}.safeReason: ${reason}`));
if (gate.gate === "enabled") {
if (gate.evidenceStatus !== "compatible") errors.push(`${gateLocation}.evidenceStatus: enabled gates require compatible evidence`);
if (!/^(sha256:)?[a-fA-F0-9]{16,128}$/.test(gate.requiredSchemaFingerprint ?? "")) errors.push(`${gateLocation}.requiredSchemaFingerprint: enabled gates require a schema fingerprint`);
if (!Array.isArray(gate.requiredAssetDigests) || gate.requiredAssetDigests.length === 0) errors.push(`${gateLocation}.requiredAssetDigests: enabled gates require immutable asset digests`);
}
if (gate.gate === "disabled" && gate.evidenceStatus === "compatible") errors.push(`${gateLocation}.evidenceStatus: disabled gates must not claim compatible evidence`);
}
return errors;
}
type GameClientBridgeSchemaReference = {
location: string;
ref: string;
@@ -1372,6 +1419,7 @@ export function validateManifestFile(manifestPath: string): string[] {
errors.push(...validateClientManagerProfiles(manifest));
errors.push(...validateDLLExtensionProfiles(manifest));
errors.push(...validateGameClientBridgeCatalog(manifest));
errors.push(...validateSCUMLiveDataManifest(manifest));
errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir));
errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir));
errors.push(...validateRuntimeLogEventCatalog(manifest));
+51
View File
@@ -43,6 +43,7 @@ export type RunCapability =
| "remote.run.process.start"
| "remote.run.process.stop"
| "remote.run.db.mysql.query"
| "remote.run.db.sqlite.probe"
| "remote.run.db.sqlite.query"
| "remote.run.logs.transfer"
| "remote.run.rcon.command"
@@ -268,6 +269,55 @@ export interface GameClientBridgeQueryTemplateDeclaration {
export type GameClientBridgeOperationKind = "rcon" | "sqlite-mutation";
export type SCUMLiveDataCapability =
| "schema-probe"
| "players.read"
| "player-details.read"
| "squads.read"
| "squad-members.read"
| "vehicles.read"
| "flags.read"
| "positions.read"
| "profile-xml.write"
| "economy-command.write"
| "gift-command.write";
export type SCUMLiveDataGateState = "disabled" | "enabled";
export type SCUMLiveDataEvidenceStatus = "missing" | "compatible" | "incompatible" | "failed";
export interface SCUMLiveDataCapabilityGateDeclaration {
capability: SCUMLiveDataCapability;
gate: SCUMLiveDataGateState;
adapterVersion: string;
requiredSchemaFingerprint?: string;
requiredAssetDigests?: `sha256:${string}`[];
evidenceStatus: SCUMLiveDataEvidenceStatus;
safeReason: string;
}
export interface SCUMSchemaProbeBoundsDeclaration {
maxObjects: number;
maxColumnsPerObject: number;
maxIndexesPerObject: number;
maxForeignKeys: number;
maxCardinalityReads: number;
maxSampleRows: number;
timeoutMs: number;
maxResultBytes: number;
}
export interface SCUMSchemaProbeDeclaration {
capability: Extract<RunCapability, "remote.run.db.sqlite.probe">;
targetKey: string;
bounds: SCUMSchemaProbeBoundsDeclaration;
}
export interface SCUMLiveDataManifestDeclaration {
schemaVersion: "1";
probe: SCUMSchemaProbeDeclaration;
capabilityGates: SCUMLiveDataCapabilityGateDeclaration[];
}
export interface GameClientBridgeOperationSafety {
requiresApproval?: boolean;
requiresOfflinePlayer?: boolean;
@@ -722,6 +772,7 @@ export interface GamePluginManifest {
remoteAccess?: GamePluginRemoteAccess;
runtimeProfiles?: GamePluginRuntimeProfiles;
gameClientBridge?: GameClientBridgeManifest;
scumLiveData?: SCUMLiveDataManifestDeclaration;
actions?: GamePluginActions;
assetFiles?: PluginAssetFile[];
productionLifecycle: {
+20
View File
@@ -30,6 +30,7 @@ import {
type GameClientBridgeProtectedRequestDeclaration,
type GameClientBridgeCompanionDeclaration,
type GamePluginManifest,
type SCUMLiveDataManifestDeclaration,
type RuntimeLogEventDeclaration,
type RuntimeClientManagerProfile,
type PluginLifecycleActionDeclaration,
@@ -202,6 +203,25 @@ describe("plugin manifest validation", () => {
expect(fs.existsSync(path.join(pluginDir, "schemas/bridge/queries/SCUM_DB_CONTRACT.md"))).toBe(true);
});
it("keeps SCUM database-backed live-data gates disabled until current-service evidence exists", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"), "utf8")) as GamePluginManifest & { scumLiveData: SCUMLiveDataManifestDeclaration; remoteAccess: { runCapabilities: string[] } };
expect(manifest.capabilities).toContain("remote.run.db.sqlite.probe");
expect(manifest.remoteAccess.runCapabilities).toContain("remote.run.db.sqlite.probe");
expect(manifest.scumLiveData.probe).toMatchObject({ capability: "remote.run.db.sqlite.probe", targetKey: "scum-database" });
expect(manifest.scumLiveData.capabilityGates.map((gate) => gate.capability)).toEqual(expect.arrayContaining(["players.read", "squads.read", "vehicles.read", "flags.read", "positions.read", "profile-xml.write", "economy-command.write", "gift-command.write"]));
expect(manifest.scumLiveData.capabilityGates.every((gate) => gate.gate === "disabled" && gate.evidenceStatus === "missing")).toBe(true);
expect(JSON.stringify(manifest.scumLiveData).toLowerCase()).not.toMatch(/select\s+.+from|sqlite:\/\/|mysql:\/\/|password|credential|socket|hostpath/);
});
it("rejects enabling SCUM live-data gates without compatible evidence and immutable digests", () => {
const errors = validateTemporaryScumCompanionManifest((manifest) => {
manifest.scumLiveData.capabilityGates[1] = { ...manifest.scumLiveData.capabilityGates[1], gate: "enabled", evidenceStatus: "missing" };
});
expect(errors.some((error) => error.includes("enabled gates require compatible evidence"))).toBe(true);
expect(errors.some((error) => error.includes("enabled gates require a schema fingerprint"))).toBe(true);
expect(errors.some((error) => error.includes("enabled gates require immutable asset digests"))).toBe(true);
});
it("declares SCUM install/update and start lifecycle through plugin assets", () => {
const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin");
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as any;