diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/design.md b/openspec/changes/move-scum-feature-ownership-to-plugin/design.md index db80529..e925d65 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/design.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/design.md @@ -1,29 +1,33 @@ ## Design -The platform owns only reusable authorization, server isolation, auditing, -queues, opaque storage, and channels to Run. The SCUM plugin owns its page, -allowlists, schemas, event parsers, and Companion adapters. `platform_web` -mounts the declared plugin page generically. +The platform owns reusable authorization, tenant/server isolation, approvals, +auditing, expiry, queues, protected storage, and channels to Run. The SCUM +plugin owns its page, request generation, schemas, event parsers, and +Companion adapters. `platform_web` mounts the declared plugin page generically. -Run emits SCUM process stdout/stderr records through the durable log channel; -these are not server execution logs. The Companion parses only declared, +A bridge command may declare a protected request transport of kind `sql`, +`rcon`, or `program`. The declaration names only logical transport and target +keys plus a bounded text field; it cannot name a DSN, path, socket, credential, +or executable. A plugin can generate the request text, but Platform retains it +as protected payload, emits only redacted audit metadata, and forwards it only +after the normal server scope, permission, approval, expiry, and queue checks. +Run consumes a fenced, server-bound authorized request and resolves secrets and +the actual transport locally. Platform does not parse game-specific SQL, RCON, +or program syntax. `program` means a management-program transport accepted by +Run policy, never an operating-system shell. + +Run emits SCUM process stdout/stderr console records through the durable log +channel; these are not file execution logs. The Companion parses only declared, bounded record formats into semantic events. Unknown records make a bounded diagnostic and are skipped. A per-server correlation digest may be derived locally but never includes a raw network value in an upload. -The Companion receives only typed commands and invokes only registered typed -ports. Its game-data port exposes allowlisted player, vehicle, and position -data as bounded projections, never DSNs, paths, credentials, or rows. Fixed -server-management ports expose only declared operations. State changes read -the precondition, verify a safe window, write allowed fields, then confirm the -write. Reward delivery freezes a grant and maps each receipt to delivered, -failed, or unknown without retrying unknown outcomes. A command's failure or -unknown result affects that command alone. +The Companion receives only authorized, server-bound bridge commands and +bounded console records. Plugins, pages, and AI never receive DSNs, paths, +credentials, raw connections, sockets, or shell access. Run results are bounded +to `succeeded`, `failed`, or `unknown` with safe diagnostics. A request failure, +unknown text, or unsupported field affects that request alone. Runtime capability/schema probes decide whether a particular handler is available. They do not depend on a server/game/UE4SS/database version, build, or source revision, and a failed probe never disables unrelated features. - -`vehicle.spawn` is the one fixed administration template. It accepts only a -catalogued identifier and builds exactly `#spawnvehicle ` inside -the Companion. The text stays private to its typed transport/audit boundary. diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/implementation-blockers.md b/openspec/changes/move-scum-feature-ownership-to-plugin/implementation-blockers.md index 9e7667f..7a23d55 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/implementation-blockers.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/implementation-blockers.md @@ -5,13 +5,16 @@ features are not disabled by an update string. The Companion uses typed, platform-authorized non-production fixtures for configuration, player-state, reward, notification, and vehicle operations; no remote server is contacted. -Run's required integration boundary is a bounded stdout/stderr record stream, -typed database projections, and fixed administration ports. It must not expose -paths, DSNs, credentials, raw rows, arbitrary SQL, shell, socket, or RCON to -the plugin, platform web, or AI. Unknown console formats create a bounded -diagnostic and no event. +Run's required integration boundary is a bounded stdout/stderr console record +stream and declared protected SQL, RCON, or management-program transports. +Plugins generate bounded request text, while Platform authorizes, approves, +queues, redacts, and forwards it only to the bound Run request. Run alone +resolves its local transport. Paths, DSNs, credentials, raw connections, host +paths, sockets, and host OS shell access never reach the plugin, platform web, +or AI. Unknown console or request formats create a bounded diagnostic for the +affected request and no fabricated event. Remaining production enablement is operational: a deployed Run implementation -must provide the declared typed ports. Until then only the affected operation +must provide the declared protected transports. Until then only the affected operation is reported unavailable; the plugin page and unrelated feature capabilities remain active. diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/ownership-audit.md b/openspec/changes/move-scum-feature-ownership-to-plugin/ownership-audit.md index 3882968..45d279a 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/ownership-audit.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/ownership-audit.md @@ -8,8 +8,8 @@ plugin bundle and Companion channel. | --- | --- | --- | --- | | `api/game_player_handlers.go`, `service/game_players.go`, `domain/game_players.go` | SCUM player profiles, sessions, risk projections | `features/players` page data projected from declared `scum.login`/`scum.logout` semantic events | Companion parser and event uploader | | `api/game_map_trajectory_handlers.go`, `service/game_map_trajectories.go`, `domain/game_map_trajectories.go` | SCUM map conversion and trajectory projection | `features/trajectories` catalog and page projection | A verified server-side source; otherwise the page remains unavailable | -| `api/game_gift_handlers.go`, `service/game_gifts.go`, `domain/game_gifts.go` | SCUM catalog, frozen revisions and grant workflow | `features/rewards` contracts plus `reward.deliver` and `player.notify` handlers | Compatible Companion reward handler and approved revision | -| `api/game_player_handlers.go`, `service/game_player_state_patch.go`, `domain/game_player_state_patch.go` | SCUM field catalog and state-patch approval | `features/state-patches` versioned field catalog and `game-state.patch` handler | Version discovery and verified safe window | +| `api/game_gift_handlers.go`, `service/game_gifts.go`, `domain/game_gifts.go` | SCUM catalog and grant workflow | `features/rewards` contracts plus declared protected request handlers | Available server-bound handler and approval | +| `api/game_player_handlers.go`, `service/game_player_state_patch.go`, `domain/game_player_state_patch.go` | SCUM field catalog and state-patch approval | `features/state-patches` declarative field catalog and protected request handler | Runtime schema availability and approval | | `components/ScumFileConfigWorkbench.tsx` | SCUM configuration workbench | SCUM page bundle configuration catalog | Companion `config.read`/`config.patch` availability | | `components/GamePlayerIntelligencePanel.tsx`, `GameGiftCatalogPanel.tsx`, `ScumMapTrajectoryPanel.tsx` | SCUM panels imported by the host | SCUM page bundle module | Generic manifest bundle validation and plugin-page host | | `contracts/scumOperations.ts`, `schemas/scumOperations.ts` | `game.scum` host branch | manifest-driven bundle contract | Generic page-bundle registry | diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/proposal.md b/openspec/changes/move-scum-feature-ownership-to-plugin/proposal.md index d0a2562..66b1206 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/proposal.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/proposal.md @@ -2,22 +2,32 @@ SCUM plugin behavior must survive server updates without treating a game, UE4SS, database, build, or revision string as a feature kill switch. The prior plan -incorrectly used static compatibility gates. +also incorrectly treated plugin-generated SQL and management-command text as a +direct-access surface. Generating text is distinct from possessing a DSN, RCON +credential, host path, socket, or shell capability. ## What Changes - Move all SCUM feature authority to the plugin and its Companion, with generic - platform authorization, isolation, audit, queue, storage, and Run channels. + platform authorization, isolation, approval, audit, expiry, queue, protected + storage, and Run channels. - Replace build/version/revision gates with runtime schema and capability probes. -- Let Run provide bounded SCUM stdout/stderr records, typed database reads, and - fixed administration operations only through platform-authorized channels. -- Require field allowlists, pre-read/safe-window/write-confirmation flows, and - `succeeded`/`failed`/`unknown` results for mutating adapters. -- Preserve fixed-template `vehicle.spawn`; its private `#spawnvehicle - ` audit text never enters a result or page payload. +- Let plugins declare and generate bounded SQL, RCON, or program-management + request text for a logical, server-bound protected transport. Platform stores, + authorizes, approves, audits, expires, and forwards that opaque payload; Run + alone resolves the bound transport and executes the authorized request. +- Keep platform transport-agnostic: it validates declarations, scope, limits, + lifecycle, and redaction but does not parse SCUM SQL, RCON, or program syntax. +- Let Run provide bounded SCUM process stdout/stderr console records through the + platform log channel for plugin parsing. These are not file execution logs. +- Require bounded `succeeded`/`failed`/`unknown` result classifications and safe + diagnostics. Unknown text, command formats, and fields affect only the one + request and never disable unrelated features. ## Non-Goals -No arbitrary RCON, SQL, shell, socket, path, DSN, credential, raw database -row, OCR, screenshot, keyboard/mouse injection, or desktop automation is -introduced. No SCUM import or `game.scum` branch is added to `platform_web`. +No plugin, page, AI request, or result projection receives a DSN, database +path, raw connection, RCON credential, host path, direct socket, or shell. +Protected program-management requests are not host OS shell requests. No OCR, +screenshot, keyboard/mouse injection, desktop automation, Run source, SCUM +import, or `game.scum` branch is added to `platform_web`. diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md index 37c38a6..490ab7d 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-companion-runtime-adapter/spec.md @@ -13,40 +13,33 @@ A probe or command failure SHALL affect only that handler invocation. - **THEN** the Companion returns a typed unavailable/failed/unknown result for that command and does not disable an unrelated plugin feature -### Requirement: Run data channels are bounded +### Requirement: Protected requests are platform mediated -Run SHALL send SCUM stdout/stderr records to the Companion through the durable -log channel and SHALL provide database data only as typed allowlisted -projections and fixed server-management operations. No plugin, web page, or -AI request SHALL receive a path, DSN, credential, raw row, arbitrary SQL, -shell, socket, or RCON command. +The SCUM plugin SHALL be able to generate bounded SQL, RCON, or +program-management request text for a declared logical protected transport. +Platform SHALL authorize, isolate by tenant and server, approve, audit with +redaction, expire, queue, store, and forward each opaque request to the bound +Run endpoint. Platform SHALL not parse SCUM SQL, RCON, or program syntax. Run +SHALL execute only a current, authorized, server-bound request and return a +bounded `succeeded`, `failed`, or `unknown` result with safe diagnostics. +No plugin, web page, or AI request SHALL receive a path, DSN, raw connection, +credential, host path, direct socket, or shell capability. + +#### Scenario: Unsupported request format + +- **WHEN** Run cannot recognize a request text, command format, or field +- **THEN** it returns `failed` or `unknown` with a safe diagnostic for that + request and does not disable an unrelated capability + +### Requirement: SCUM console records use the log channel + +Run SHALL send bounded SCUM process stdout/stderr console records through the +durable platform log channel. The Companion SHALL parse only declared bounded +formats and skip unknown lines with a bounded diagnostic. Console records are +not file execution logs. #### Scenario: Unknown console output - **WHEN** stdout or stderr does not match a declared semantic parser - **THEN** the Companion records a bounded diagnostic and uploads no semantic event or raw line - -### Requirement: Mutations prove safety - -State patch adapters SHALL use field allowlists, a pre-read, safe-window -verification, a bounded write, and read-after-write confirmation. Reward -adapters SHALL freeze their typed grant and return delivered, failed, or -unknown without automatically retrying unknown outcomes. - -#### Scenario: Confirmation cannot be established - -- **WHEN** a typed write or post-write read cannot establish success -- **THEN** the Companion returns `unknown` and does not repeat the operation - -### Requirement: Vehicle spawning remains fixed - -`vehicle.spawn` SHALL accept only a catalogued vehicle code and create exactly -`#spawnvehicle ` inside the Companion. Protected audit text SHALL -not be present in command results or browser payloads. - -#### Scenario: Unsafe spawn input - -- **WHEN** input includes an unlisted code, an extra field, command text, SQL, - a path, credential, socket, shell text, or RCON text -- **THEN** no transport call occurs and validation fails diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-plugin-feature-ownership/spec.md b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-plugin-feature-ownership/spec.md index be2cd6e..b702cb8 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-plugin-feature-ownership/spec.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/specs/scum-plugin-feature-ownership/spec.md @@ -4,7 +4,8 @@ The SCUM plugin SHALL own SCUM schemas, allowlists, migration adapters, Companion behavior, and feature UI. The platform SHALL retain only reusable -authorization, isolation, auditing, queues, storage, and generic plugin-host +authorization, isolation, approval, auditing, expiry, queues, protected +storage, generic Run transport declarations, and generic plugin-host primitives. `platform_web` SHALL not import SCUM code or branch on `game.scum`. #### Scenario: Page mounting @@ -35,3 +36,18 @@ to the server and feature, never to a game version. - **WHEN** no unique server-feature migration flag is present - **THEN** historical records remain readable and plugin writes stay disabled + +### Requirement: Protected request declarations are generic + +The plugin manifest and SDK SHALL support generic declared protected request +transports for SQL, RCON, and management-program text. Declarations SHALL use +only logical server-bound transport/target keys and bounded text fields. +Browser projections and audit records SHALL redact request text. Declarations +shall not grant credentials, paths, raw connections, direct sockets, or host OS +shell execution. + +#### Scenario: Plugin generates an SQL request + +- **WHEN** the plugin queues SQL text through a declared protected transport +- **THEN** Platform stores and audits only its protected/redacted form and + forwards it only after generic authorization and approval checks diff --git a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md index 2f8f726..8fc4d7d 100644 --- a/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md +++ b/openspec/changes/move-scum-feature-ownership-to-plugin/tasks.md @@ -1,22 +1,17 @@ -## 1. Replace version gates with runtime probes +## 1. Reopen the architecture boundary -- [x] 1.1 Remove SCUM/game/UE4SS/database build, revision, and version feature gates from the change contract, manifest-facing feature layer, Companion registry, adapters, and tests. -- [x] 1.2 Make handler availability server-bound and capability/schema-probe based; isolate failure to the affected command. +- [x] 1.1 Replace the prior SQL/RCON prohibition and fixed SCUM command template in proposal, design, and specifications with generic protected request semantics. +- [x] 1.2 Preserve runtime probe isolation while removing every SCUM/UE4SS/database build, revision, and version gate. -## 2. Establish typed Run/Companion boundaries +## 2. Define browser-side protected request contracts -- [x] 2.1 Define restricted typed ports for configuration, player state, rewards, notifications, and fixed server administration with no raw paths, DSNs, rows, credentials, SQL, shell, sockets, or RCON. -- [x] 2.2 Parse bounded Run stdout/stderr records into semantic events; skip unknown formats with bounded diagnostics and irreversible per-server correlation. -- [x] 2.3 Implement state pre-read, safe-window, allowlisted write, and read-after-write confirmation with typed results. -- [x] 2.4 Implement frozen typed reward delivery results without automatic unknown retries. -- [x] 2.5 Preserve the fixed private vehicle-spawn template and its allowlist. +- [x] 2.1 Add generic SQL, RCON, and management-program protected transport declarations to the manifest schema, platform domain validation, and plugin SDK. +- [x] 2.2 Permit only a declared bounded request-text field for protected commands; continue rejecting credentials, DSNs, paths, raw connections, direct sockets, and host OS shell material. +- [x] 2.3 Keep queue, approval, server/tenant isolation, expiry, and Run-facing protected payload semantics generic; redact text from browser responses and audit summaries. +- [x] 2.4 Declare SCUM plugin protected database and management transports without adding SCUM parsing or a fixed command template. -## 3. Complete plugin-owned migration +## 3. Verify and deliver -- [x] 3.1 Remove version-scoped feature catalogs, page context, API requests, and migration flags in favor of runtime schema/capability availability. -- [x] 3.2 Keep platform records read-only with provenance and leave platform-web generic. - -## 4. Verify and deliver - -- [x] 4.1 Run Companion, plugin, manifest, OpenSpec strict, structure, and scoped source-boundary verification. -- [x] 4.2 Stage scoped files, commit, and push `main`. +- [x] 3.1 Update focused Go and TypeScript tests for declarations, request generation, redaction, and safe rejection. +- [x] 3.2 Run focused Go/TS tests, OpenSpec strict validation, and structure verification. +- [ ] 3.3 Stage scoped files, commit, and push `main` (commit created; push remains blocked by remote SSH access). diff --git a/platform/domain/game_client_bridge.go b/platform/domain/game_client_bridge.go index c6ed841..2a6549d 100644 --- a/platform/domain/game_client_bridge.go +++ b/platform/domain/game_client_bridge.go @@ -9,6 +9,7 @@ const ( GameClientBridgeCommandClaimed GameClientBridgeCommandState = "claimed" GameClientBridgeCommandSucceeded GameClientBridgeCommandState = "succeeded" GameClientBridgeCommandFailed GameClientBridgeCommandState = "failed" + GameClientBridgeCommandUnknown GameClientBridgeCommandState = "unknown" GameClientBridgeCommandCancelled GameClientBridgeCommandState = "cancelled" GameClientBridgeCommandExpired GameClientBridgeCommandState = "expired" ) @@ -39,6 +40,17 @@ type GameClientBridgeCommandDeclaration struct { ResultSchemaRef string TimeoutSeconds int MaxPayloadBytes int + ProtectedRequest *GameClientBridgeProtectedRequestDeclaration +} + +// GameClientBridgeProtectedRequestDeclaration binds plugin-generated text to a +// logical server transport. It never carries its resolved connection details. +type GameClientBridgeProtectedRequestDeclaration struct { + Kind string + TransportKey string + TargetKey string + TextField string + MaxTextBytes int } type GameClientBridgeSnapshotDeclaration struct { @@ -108,6 +120,7 @@ type GameClientBridgeResultStatus string const ( GameClientBridgeResultSucceeded GameClientBridgeResultStatus = "succeeded" GameClientBridgeResultFailed GameClientBridgeResultStatus = "failed" + GameClientBridgeResultUnknown GameClientBridgeResultStatus = "unknown" GameClientBridgeResultCancelled GameClientBridgeResultStatus = "cancelled" ) @@ -384,6 +397,12 @@ func CopyGameClientBridgePayload(value map[string]any) map[string]any { func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBridgeManifest { value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...) + for index := range value.Commands { + if value.Commands[index].ProtectedRequest != nil { + copy := *value.Commands[index].ProtectedRequest + value.Commands[index].ProtectedRequest = © + } + } value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...) value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...) value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...) diff --git a/platform/domain/resources.go b/platform/domain/resources.go index fdd5133..c2aa1bc 100644 --- a/platform/domain/resources.go +++ b/platform/domain/resources.go @@ -1045,6 +1045,9 @@ const ( JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query" JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer" JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command" + JobCapabilityRemoteRunProtectedSQL = "remote.run.protected.sql" + JobCapabilityRemoteRunProtectedRCON = "remote.run.protected.rcon" + JobCapabilityRemoteRunProgram = "remote.run.program.command" JobCapabilityRunSelfUpdate = "run.self-update" JobCapabilityDistributionBuild = "distribution.build" JobCapabilityDependenciesCheck = "dependencies.check" diff --git a/platform/dto/resources.go b/platform/dto/resources.go index 7b1840c..2f42b3c 100644 --- a/platform/dto/resources.go +++ b/platform/dto/resources.go @@ -262,14 +262,23 @@ type GamePluginRemoteAccessBody struct { } type GameClientBridgeCommandDeclarationBody struct { - Type string `json:"type"` - Title string `json:"title"` - Permission string `json:"permission"` - ApprovalLevel string `json:"approvalLevel"` - PayloadSchemaRef string `json:"payloadSchemaRef"` - ResultSchemaRef string `json:"resultSchemaRef,omitempty"` - TimeoutSeconds int `json:"timeoutSeconds"` - MaxPayloadBytes int `json:"maxPayloadBytes"` + Type string `json:"type"` + Title string `json:"title"` + Permission string `json:"permission"` + ApprovalLevel string `json:"approvalLevel"` + PayloadSchemaRef string `json:"payloadSchemaRef"` + ResultSchemaRef string `json:"resultSchemaRef,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds"` + MaxPayloadBytes int `json:"maxPayloadBytes"` + ProtectedRequest *GameClientBridgeProtectedRequestDeclarationBody `json:"protectedRequest,omitempty"` +} + +type GameClientBridgeProtectedRequestDeclarationBody struct { + Kind string `json:"kind"` + TransportKey string `json:"transportKey"` + TargetKey string `json:"targetKey"` + TextField string `json:"textField"` + MaxTextBytes int `json:"maxTextBytes"` } type GameClientBridgeSnapshotDeclarationBody struct { @@ -1110,7 +1119,7 @@ func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAcces func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManifest { commands := make([]domain.GameClientBridgeCommandDeclaration, len(body.Commands)) for index, command := range body.Commands { - commands[index] = domain.GameClientBridgeCommandDeclaration{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes} + commands[index] = domain.GameClientBridgeCommandDeclaration{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: domain.GameClientBridgeApprovalLevel(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes, ProtectedRequest: protectedRequestToDomain(command.ProtectedRequest)} } snapshots := make([]domain.GameClientBridgeSnapshotDeclaration, len(body.Snapshots)) for index, snapshot := range body.Snapshots { @@ -1135,6 +1144,13 @@ func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManif return domain.GameClientBridgeManifest{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, Retention: domain.GameClientBridgeRetention{KeepForSeconds: body.CommandRetentionSeconds, MaxRecords: body.MaxCommands}, Pages: pages, Features: features, Companion: companion} } +func protectedRequestToDomain(value *GameClientBridgeProtectedRequestDeclarationBody) *domain.GameClientBridgeProtectedRequestDeclaration { + if value == nil { + return nil + } + return &domain.GameClientBridgeProtectedRequestDeclaration{Kind: value.Kind, TransportKey: value.TransportKey, TargetKey: value.TargetKey, TextField: value.TextField, MaxTextBytes: value.MaxTextBytes} +} + func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions { return domain.PluginLifecycleActions{ Install: actions.Install, @@ -1527,7 +1543,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G value = domain.CopyGameClientBridgeManifest(value) commands := make([]GameClientBridgeCommandDeclarationBody, len(value.Commands)) for index, command := range value.Commands { - commands[index] = GameClientBridgeCommandDeclarationBody{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: string(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes} + commands[index] = GameClientBridgeCommandDeclarationBody{Type: command.Type, Title: command.Title, Permission: command.Permission, ApprovalLevel: string(command.ApprovalLevel), PayloadSchemaRef: command.PayloadSchemaRef, ResultSchemaRef: command.ResultSchemaRef, TimeoutSeconds: command.TimeoutSeconds, MaxPayloadBytes: command.MaxPayloadBytes, ProtectedRequest: protectedRequestFromDomain(command.ProtectedRequest)} } snapshots := make([]GameClientBridgeSnapshotDeclarationBody, len(value.Snapshots)) for index, snapshot := range value.Snapshots { @@ -1552,6 +1568,13 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G return GameClientBridgeManifestBody{Commands: commands, Snapshots: snapshots, QueryTemplates: queryTemplates, CommandRetentionSeconds: value.Retention.KeepForSeconds, MaxCommands: value.Retention.MaxRecords, Pages: pages, Features: features, Companion: companion} } +func protectedRequestFromDomain(value *domain.GameClientBridgeProtectedRequestDeclaration) *GameClientBridgeProtectedRequestDeclarationBody { + if value == nil { + return nil + } + return &GameClientBridgeProtectedRequestDeclarationBody{Kind: value.Kind, TransportKey: value.TransportKey, TargetKey: value.TargetKey, TextField: value.TextField, MaxTextBytes: value.MaxTextBytes} +} + func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse { items := make([]MarketplacePluginResponse, len(plugins)) for i, plugin := range plugins { diff --git a/platform/service/game_client_bridge.go b/platform/service/game_client_bridge.go index ab372ad..db4b72b 100644 --- a/platform/service/game_client_bridge.go +++ b/platform/service/game_client_bridge.go @@ -1,6 +1,7 @@ package service import ( + "crypto/sha256" "encoding/json" "fmt" "reflect" @@ -251,6 +252,30 @@ func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQue return values } +func validateProtectedGameClientBridgePayload(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) error { + if declaration == nil { + return nil + } + if len(payload) != 1 { + return validationError("protected bridge request must contain only its declared text field") + } + value, exists := payload[declaration.TextField] + if !exists { + return validationError("protected bridge request text field is required") + } + text, ok := value.(string) + if !ok || len([]byte(text)) == 0 || len([]byte(text)) > declaration.MaxTextBytes { + return validationError("protected bridge request text is invalid") + } + return nil +} + +func protectedGameClientBridgeAuditSummary(declaration *domain.GameClientBridgeProtectedRequestDeclaration, payload map[string]any) string { + text, _ := payload[declaration.TextField].(string) + digest := sha256.Sum256([]byte(text)) + return fmt.Sprintf("queued protected %s request transport=%s target=%s text=redacted sha256=%x", declaration.Kind, declaration.TransportKey, declaration.TargetKey, digest[:8]) +} + func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) { request.Payload = domain.CopyGameClientBridgePayload(request.Payload) if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil { @@ -274,6 +299,9 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) { return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout") } + if err := validateProtectedGameClientBridgePayload(declaration.ProtectedRequest, request.Payload); err != nil { + return domain.GameClientBridgeCommand{}, err + } existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey) if err == nil { @@ -312,7 +340,11 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request CreatedAt: stamp, UpdatedAt: stamp, } - auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, "queued declared game client bridge command") + summary := "queued declared game client bridge command" + if declaration.ProtectedRequest != nil { + summary = protectedGameClientBridgeAuditSummary(declaration.ProtectedRequest, request.Payload) + } + auditID, err := svc.recordAuditEventWithID(requesterID, "game-client-bridge.command.queue", "game-client-bridge-command", command.ID, domain.AuditResultQueued, summary) if err != nil { return domain.GameClientBridgeCommand{}, err } @@ -426,6 +458,8 @@ func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBrid command.State = domain.GameClientBridgeCommandSucceeded case domain.GameClientBridgeResultFailed: command.State = domain.GameClientBridgeCommandFailed + case domain.GameClientBridgeResultUnknown: + command.State = domain.GameClientBridgeCommandUnknown case domain.GameClientBridgeResultCancelled: command.State = domain.GameClientBridgeCommandCancelled } @@ -684,7 +718,7 @@ func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, compon func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool { switch state { - case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired: + case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandUnknown, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired: return true default: return false diff --git a/platform/service/game_client_bridge_test.go b/platform/service/game_client_bridge_test.go index d7adc78..c51cf5d 100644 --- a/platform/service/game_client_bridge_test.go +++ b/platform/service/game_client_bridge_test.go @@ -1,6 +1,7 @@ package service import ( + "strings" "testing" "time" @@ -82,6 +83,38 @@ func TestGameClientBridgeCommandLifecycleAndIdempotency(t *testing.T) { } } +func TestProtectedGameClientBridgeRequestIsScopedAndRedacted(t *testing.T) { + svc, clock := newGameClientBridgeService(t) + plugin, err := svc.store.GamePlugins().Get("game.scum") + if err != nil { + t.Fatal(err) + } + plugin.RuntimeProfiles.TransportProfiles = []domain.RuntimeTransportProfile{{Key: "database", Kind: "sqlite", TargetKey: "database", Capabilities: []string{domain.JobCapabilityRemoteRunProtectedSQL}}} + plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "database.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelPlatformAdmin, TimeoutSeconds: 60, MaxPayloadBytes: 4096, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "sql", TransportKey: "database", TargetKey: "database", TextField: "requestText", MaxTextBytes: 1024}}) + if err := svc.store.GamePlugins().Update(plugin); err != nil { + t.Fatal(err) + } + text := "UPDATE players SET rank = 2 WHERE id = 7" + request := domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text}, IdempotencyKey: "protected-1", ExpiresAt: clock.Add(time.Minute)} + command, err := svc.queueGameClientBridgeCommand("user-1", request) + if err != nil { + t.Fatalf("queue protected request: %v", err) + } + if command.ApprovalState != domain.GameClientBridgeApprovalPending { + t.Fatalf("protected request bypassed approval: %#v", command) + } + if _, err := svc.queueGameClientBridgeCommand("user-1", domain.GameClientBridgeQueueRequest{ServerInstanceID: "server-1", PluginID: "game.scum", ProfileKey: "scum-client", CommandType: "database.request", Payload: map[string]any{"requestText": text, "unexpected": true}, IdempotencyKey: "protected-extra", ExpiresAt: clock.Add(time.Minute)}); err == nil { + t.Fatal("protected request accepted undeclared payload field") + } + events, err := svc.store.AuditEvents().List(domain.AuditEventFilter{ResourceID: command.ID}) + if err != nil || len(events) != 1 { + t.Fatalf("protected request audit: events=%#v err=%v", events, err) + } + if strings.Contains(events[0].Summary, text) || !strings.Contains(events[0].Summary, "text=redacted") { + t.Fatalf("audit leaked protected request: %#v", events[0]) + } +} + func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) { svc, clock := newGameClientBridgeService(t) request := bridgeQueueRequest(*clock, "scope-key") diff --git a/platform/service/scum_deployment_test.go b/platform/service/scum_deployment_test.go index 53d1213..99d8ce1 100644 --- a/platform/service/scum_deployment_test.go +++ b/platform/service/scum_deployment_test.go @@ -19,7 +19,7 @@ func scumDeploymentTestPlugin() domain.GamePlugin { Key: "scum-steamcmd-windows", Version: "1.0.0", SteamAppID: "3792580", ExecutableKey: "scum/server-executable", InstallRootKey: "server/install-root", ConfigKey: "scum/server-settings", ConfigFormat: "ini", Prerequisites: []domain.RuntimeServerPrerequisite{{Key: "steamcmd", Kind: "steamcmd"}, {Key: "vcredist-2012-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2012-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2013-x64", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x86", Kind: "windows-vcredist"}, {Key: "vcredist-2015-2022-x64", Kind: "windows-vcredist"}, {Key: "directx-jun2010", Kind: "windows-directx"}}, ConfigMappings: []domain.RuntimeServerConfigMapping{{FieldKey: "serverName", ConfigKey: "server-settings.server-name", ValueType: "text", Required: true}, {FieldKey: "gamePort", ConfigKey: "server-settings.game-port", ValueType: "port", Required: true}, {FieldKey: "queryPort", ConfigKey: "server-settings.query-port", ValueType: "port", Required: true}, {FieldKey: "maxPlayers", ConfigKey: "server-settings.max-players", ValueType: "integer", Required: true}}, - VerificationChecks: []domain.RuntimeServerVerificationCheck{{Key: "executable", Kind: "executable.present", TargetKey: "scum/server-executable", Required: true}, {Key: "version", Kind: "version.matches", TargetKey: "scum/server-executable", Required: true}, {Key: "game-port", Kind: "port.bound", TargetKey: "game-port", Required: true}, {Key: "config", Kind: "config.readable", TargetKey: "scum/server-settings", Required: true}, {Key: "process", Kind: "process.healthy", TargetKey: "scum/server-executable", Required: true}}, + VerificationChecks: []domain.RuntimeServerVerificationCheck{{Key: "executable", Kind: "executable.present", TargetKey: "scum/server-executable", Required: true}, {Key: "game-port", Kind: "port.bound", TargetKey: "game-port", Required: true}, {Key: "config", Kind: "config.readable", TargetKey: "scum/server-settings", Required: true}, {Key: "process", Kind: "process.healthy", TargetKey: "scum/server-executable", Required: true}}, }}}, } } diff --git a/platform/validator/game_client_bridge.go b/platform/validator/game_client_bridge.go index 7f05851..3af4704 100644 --- a/platform/validator/game_client_bridge.go +++ b/platform/validator/game_client_bridge.go @@ -72,7 +72,7 @@ func ValidateGameClientBridgeResultRequest(request domain.GameClientBridgeResult if request.FencingToken == 0 { violations = append(violations, "fencingToken is required") } - if request.Status != domain.GameClientBridgeResultSucceeded && request.Status != domain.GameClientBridgeResultFailed && request.Status != domain.GameClientBridgeResultCancelled { + if request.Status != domain.GameClientBridgeResultSucceeded && request.Status != domain.GameClientBridgeResultFailed && request.Status != domain.GameClientBridgeResultUnknown && request.Status != domain.GameClientBridgeResultCancelled { violations = append(violations, "status is invalid") } violations = appendGameClientBridgeText(violations, "summary", request.Summary, 512) diff --git a/platform/validator/game_client_bridge_test.go b/platform/validator/game_client_bridge_test.go index 0d74484..6431415 100644 --- a/platform/validator/game_client_bridge_test.go +++ b/platform/validator/game_client_bridge_test.go @@ -52,7 +52,7 @@ func TestValidateGameClientBridgeRequestFieldBounds(t *testing.T) { {name: "claim token", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: " session"}), want: "sessionToken"}, {name: "claim limit", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: "session", Limit: 51}), want: "limit"}, {name: "ack fence", err: ValidateGameClientBridgeAckRequest(domain.GameClientBridgeAckRequest{SessionToken: "session", CommandID: "command-1"}), want: "fencingToken"}, - {name: "result state", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: "unknown"}), want: "status"}, + {name: "result state", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: "unexpected"}), want: "status"}, {name: "result text", err: ValidateGameClientBridgeResultRequest(domain.GameClientBridgeResultRequest{SessionToken: "session", CommandID: "command-1", FencingToken: 1, Status: domain.GameClientBridgeResultFailed, Summary: "read /etc/passwd"}), want: "unsafe"}, {name: "cancel text", err: ValidateGameClientBridgeCancelRequest(domain.GameClientBridgeCancelRequest{CommandID: "command-1", Reason: "Bearer private"}), want: "unsafe"}, {name: "snapshot payload", err: ValidateGameClientBridgeSnapshotIngestRequest(domain.GameClientBridgeSnapshotIngestRequest{SessionToken: "session", Type: "players", SchemaVersion: "1", StreamKey: "current", Sequence: 1, ObservedAt: time.Now(), Retention: domain.GameClientBridgeRetention{KeepForSeconds: 1}}), want: "payload"}, diff --git a/platform/validator/resources.go b/platform/validator/resources.go index 98a735b..678d6a2 100644 --- a/platform/validator/resources.go +++ b/platform/validator/resources.go @@ -420,10 +420,14 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid violations = append(violations, prefix+".profileKey must reference a declared Client Manager profile") } } + transports := map[string]domain.RuntimeTransportProfile{} + for _, transport := range runtimeProfiles.TransportProfiles { + transports[transport.Key] = transport + } commandTypes := map[string]struct{}{} for index, command := range bridge.Commands { prefix := fmt.Sprintf("%s.commands[%d]", field, index) - if !clientManagerIdentifierPattern.MatchString(command.Type) || unsafeGameClientBridgeCommandType(command.Type) { + if !clientManagerIdentifierPattern.MatchString(command.Type) || command.ProtectedRequest == nil && unsafeGameClientBridgeCommandType(command.Type) { violations = append(violations, prefix+".type is invalid or unsafe") } if _, exists := commandTypes[command.Type]; exists { @@ -448,6 +452,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize { violations = append(violations, prefix+".maxPayloadBytes is invalid") } + violations = append(violations, validateGameClientBridgeProtectedRequest(prefix+".protectedRequest", command.ProtectedRequest, transports)...) } snapshotTypes := map[string]struct{}{} for index, snapshot := range bridge.Snapshots { @@ -468,10 +473,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid } } queryTemplates := map[string]domain.GameClientBridgeQueryTemplateDeclaration{} - transports := map[string]domain.RuntimeTransportProfile{} - for _, transport := range runtimeProfiles.TransportProfiles { - transports[transport.Key] = transport - } for index, template := range bridge.QueryTemplates { prefix := fmt.Sprintf("%s.queryTemplates[%d]", field, index) if !clientManagerIdentifierPattern.MatchString(template.Key) { @@ -649,6 +650,50 @@ func unsafeGameClientBridgeCommandType(value string) bool { return has("shell", "powershell", "script", "terminal", "execute", "exec", "eval") || has("command", "cmd", "process", "system", "os", "executor") && has("run") } +func validateGameClientBridgeProtectedRequest(prefix string, request *domain.GameClientBridgeProtectedRequestDeclaration, transports map[string]domain.RuntimeTransportProfile) []string { + if request == nil { + return nil + } + var violations []string + if !oneOf(request.Kind, "sql", "rcon", "program") { + violations = append(violations, prefix+".kind is invalid") + } + for field, value := range map[string]string{"transportKey": request.TransportKey, "targetKey": request.TargetKey, "textField": request.TextField} { + if !validDistributionLogicalKey(value) || unsafeGameClientBridgePayloadKey(value) { + violations = append(violations, prefix+"."+field+" is invalid") + } + } + if request.MaxTextBytes < 1 || request.MaxTextBytes > maxGameClientBridgePayloadString { + violations = append(violations, prefix+".maxTextBytes is invalid") + } + transport, exists := transports[request.TransportKey] + if !exists { + return append(violations, prefix+".transportKey must reference a declared runtime transport profile") + } + if transport.TargetKey != request.TargetKey { + violations = append(violations, prefix+".targetKey must match the declared runtime transport profile") + } + wantKind, wantCapability := "", "" + switch request.Kind { + case "sql": + wantCapability = domain.JobCapabilityRemoteRunProtectedSQL + case "rcon": + wantKind, wantCapability = "rcon", domain.JobCapabilityRemoteRunProtectedRCON + case "program": + wantKind, wantCapability = "program", domain.JobCapabilityRemoteRunProgram + } + if request.Kind == "sql" && transport.Kind != "mysql" && transport.Kind != "sqlite" { + violations = append(violations, prefix+".transportKey must use mysql or sqlite for sql requests") + } + if wantKind != "" && transport.Kind != wantKind { + violations = append(violations, prefix+".transportKey does not match protected request kind") + } + if wantCapability != "" && !containsString(transport.Capabilities, wantCapability) { + violations = append(violations, prefix+".transportKey is missing required protected transport capability") + } + return violations +} + func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error { var violations []string violations = appendRequired(violations, "pluginId", request.PluginID) @@ -1902,6 +1947,7 @@ func validPluginRunCapability(capability string) bool { domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, + domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, @@ -1934,7 +1980,8 @@ func remoteCapabilityRequiresInputRef(capability string) bool { domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, - domain.JobCapabilityRemoteRunRCONCommand: + domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL, + domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram: return true default: return false diff --git a/platform/validator/runtime_profiles.go b/platform/validator/runtime_profiles.go index 8445d46..5fb7657 100644 --- a/platform/validator/runtime_profiles.go +++ b/platform/validator/runtime_profiles.go @@ -296,7 +296,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i) violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...) violations = append(violations, recordRuntimeProfileKey(transportKeys, prefix+".key", transport.Key)...) - if !oneOf(transport.Kind, "file", "ftp", "rsync", "mysql", "sqlite", "rcon") { + if !oneOf(transport.Kind, "file", "ftp", "rsync", "mysql", "sqlite", "rcon", "program") { violations = append(violations, prefix+".kind is invalid") } if transport.TargetKey != "" { @@ -480,7 +480,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles } func containsRequiredVerification(checks []domain.RuntimeServerVerificationCheck) bool { - required := map[string]bool{"executable.present": false, "version.matches": false, "port.bound": false, "config.readable": false, "process.healthy": false} + required := map[string]bool{"executable.present": false, "port.bound": false, "config.readable": false, "process.healthy": false} for _, check := range checks { if check.Required { if _, ok := required[check.Kind]; ok { diff --git a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md index 6f56245..6b23398 100644 --- a/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md +++ b/plugins/examples/scum-server-plugin/companion/UE4SS_CAPABILITY.md @@ -5,12 +5,13 @@ feature gate: no SCUM game, database, UE4SS build, or source revision controls plugin availability. The Companion declares availability from its server-bound typed ports and -runtime schema probes. Notification and fixed vehicle spawning can use a local -typed transport, but callers never supply a command, socket, credential, path, -or raw transport reply. Vehicle spawning creates only the private -`#spawnvehicle ` template from the plugin allowlist. +runtime schema probes. Plugin-generated SQL, RCON, and management-program +request text is declared through the protected Platform-to-Run transport, not +executed by the Companion. Callers never receive a socket, credential, path, +or raw transport reply. A management-program request is not host OS shell +access. -Semantic events come from bounded Run stdout/stderr records. Unknown records -create diagnostics and never produce fabricated events. Run database access is -limited to typed allowlisted projections and safe mutations; DSNs, rows, SQL, -and credentials do not leave Run. +Semantic events come from bounded Run stdout/stderr console records. Unknown +records create diagnostics and never produce fabricated events. DSNs, rows, +connections, and credentials do not leave Run; request text is protected and +redacted from browser and audit projections. diff --git a/plugins/examples/scum-server-plugin/companion/adapters_test.go b/plugins/examples/scum-server-plugin/companion/adapters_test.go index ec35f8e..921c3ad 100644 --- a/plugins/examples/scum-server-plugin/companion/adapters_test.go +++ b/plugins/examples/scum-server-plugin/companion/adapters_test.go @@ -68,7 +68,7 @@ func TestRuntimeAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testi } } -func TestVersionedUE4SSNotificationIsFixedTypedAndRedacted(t *testing.T) { +func TestUE4SSNotificationIsTypedAndRedacted(t *testing.T) { port := ¬ificationPortFixture{accepted: true} adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port} result, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "76561198000000001", "message": "Moon \"gift\""}) diff --git a/plugins/examples/scum-server-plugin/companion/dispatcher.go b/plugins/examples/scum-server-plugin/companion/dispatcher.go index b0d91e2..062a0da 100644 --- a/plugins/examples/scum-server-plugin/companion/dispatcher.go +++ b/plugins/examples/scum-server-plugin/companion/dispatcher.go @@ -10,7 +10,8 @@ import ( ) // SafeAdapter is intentionally narrow: it receives typed values only and has -// no raw RCON, SQL, host-path, credential, or shell access. +// no direct transport, host-path, credential, or shell access. Protected SQL, +// RCON, and management-program text is forwarded to Run by Platform, not here. type SafeAdapter interface { ReadConfiguration(context.Context) (map[string]any, error) PatchConfiguration(context.Context, map[string]any) (map[string]any, error) diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 8d07b4e..34a72d9 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -47,6 +47,9 @@ "remote.run.process.start", "remote.run.process.stop", "remote.run.logs.transfer", + "remote.run.protected.sql", + "remote.run.protected.rcon", + "remote.run.program.command", "client-manager.deploy", "client-manager.control", "client-manager.update", @@ -67,7 +70,10 @@ "remote.run.files.write", "remote.run.process.start", "remote.run.process.stop", - "remote.run.logs.transfer" + "remote.run.logs.transfer", + "remote.run.protected.sql", + "remote.run.protected.rcon", + "remote.run.program.command" ], "logTransfer": true }, @@ -190,6 +196,39 @@ "resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json", "timeoutSeconds": 120, "maxPayloadBytes": 4096 + }, + { + "type": "database.request", + "title": "Execute approved SCUM database request", + "permission": "server.game-client.maintenance", + "approvalLevel": "platform-admin", + "payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json", + "resultSchemaRef": "schemas/bridge/protected-request.result.schema.json", + "timeoutSeconds": 120, + "maxPayloadBytes": 16384, + "protectedRequest": { "kind": "sql", "transportKey": "scum-database", "targetKey": "scum-database", "textField": "requestText", "maxTextBytes": 16384 } + }, + { + "type": "management.rcon.request", + "title": "Execute approved SCUM management command", + "permission": "server.game-client.command", + "approvalLevel": "operator", + "payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json", + "resultSchemaRef": "schemas/bridge/protected-request.result.schema.json", + "timeoutSeconds": 120, + "maxPayloadBytes": 8192, + "protectedRequest": { "kind": "rcon", "transportKey": "scum-management", "targetKey": "scum-management", "textField": "requestText", "maxTextBytes": 8192 } + }, + { + "type": "management.program.request", + "title": "Execute approved SCUM management program request", + "permission": "server.game-client.maintenance", + "approvalLevel": "platform-admin", + "payloadSchemaRef": "schemas/bridge/protected-request.payload.schema.json", + "resultSchemaRef": "schemas/bridge/protected-request.result.schema.json", + "timeoutSeconds": 120, + "maxPayloadBytes": 8192, + "protectedRequest": { "kind": "program", "transportKey": "scum-program", "targetKey": "scum-program", "textField": "requestText", "maxTextBytes": 8192 } } ], "snapshots": [ @@ -271,7 +310,10 @@ "vehicle.spawn", "event.start", "restart.prepare", - "maintenance.prepare" + "maintenance.prepare", + "database.request", + "management.rcon.request", + "management.program.request" ], "snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"], "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "vehicle.spawn", "trajectory.collect"] @@ -482,7 +524,6 @@ ], "discoveryMarkers": [ { "key": "scum-executable", "kind": "file.exists", "targetKey": "scum/server-executable", "required": true }, - { "key": "scum-version", "kind": "command.version", "targetKey": "scum/server-executable", "required": true }, { "key": "scum-steam-app", "kind": "steam.app", "targetKey": "server/install-root", "expected": "3792580", "required": true }, { "key": "scum-config", "kind": "file.exists", "targetKey": "scum/server-settings", "expected": "ServerSettings.ini", "required": true }, { "key": "scum-game-port", "kind": "port.open", "targetKey": "game-port", "required": true }, @@ -490,7 +531,6 @@ ], "verificationChecks": [ { "key": "executable", "kind": "executable.present", "targetKey": "scum/server-executable", "required": true }, - { "key": "version", "kind": "version.matches", "targetKey": "scum/server-executable", "required": true }, { "key": "game-port", "kind": "port.bound", "targetKey": "game-port", "required": true }, { "key": "config", "kind": "config.readable", "targetKey": "scum/server-settings", "required": true }, { "key": "process", "kind": "process.healthy", "targetKey": "scum/server-executable", "required": true } @@ -498,6 +538,22 @@ } ], "logSources": [ + { + "key": "scum-console-stdout", + "kind": "process.stdout", + "targetKey": "scum/server-process", + "streamKey": "scum.console.stdout", + "cursorKind": "sequence", + "retentionDays": 30 + }, + { + "key": "scum-console-stderr", + "kind": "process.stderr", + "targetKey": "scum/server-process", + "streamKey": "scum.console.stderr", + "cursorKind": "sequence", + "retentionDays": 30 + }, { "key": "scum-chat-events", "kind": "file.tail", @@ -686,6 +742,24 @@ "remote.rsync.read", "remote.rsync.write" ] + }, + { + "key": "scum-database", + "kind": "sqlite", + "targetKey": "scum-database", + "capabilities": ["remote.run.protected.sql"] + }, + { + "key": "scum-management", + "kind": "rcon", + "targetKey": "scum-management", + "capabilities": ["remote.run.protected.rcon"] + }, + { + "key": "scum-program", + "kind": "program", + "targetKey": "scum-program", + "capabilities": ["remote.run.program.command"] } ], "clientManagers": [ @@ -742,10 +816,6 @@ "offlineAfterSeconds": 120, "requiredCapabilities": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"] }, - "compatibility": { - "minimumVersion": "1.0.0", - "allowDowngrade": false - }, "updatePolicy": { "strategy": "manual-staged", "requireApproval": true, diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/protected-request.payload.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/protected-request.payload.schema.json new file mode 100644 index 0000000..b707648 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/protected-request.payload.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["requestText"], + "properties": { "requestText": { "type": "string", "minLength": 1, "maxLength": 16384 } } +} diff --git a/plugins/examples/scum-server-plugin/schemas/bridge/protected-request.result.schema.json b/plugins/examples/scum-server-plugin/schemas/bridge/protected-request.result.schema.json new file mode 100644 index 0000000..69a6036 --- /dev/null +++ b/plugins/examples/scum-server-plugin/schemas/bridge/protected-request.result.schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["outcome"], + "properties": { "outcome": { "enum": ["succeeded", "failed", "unknown"] }, "diagnostic": { "type": "string", "maxLength": 512 } } +} diff --git a/plugins/manifests/game-plugin.manifest.schema.json b/plugins/manifests/game-plugin.manifest.schema.json index b5c6a72..26a89e4 100644 --- a/plugins/manifests/game-plugin.manifest.schema.json +++ b/plugins/manifests/game-plugin.manifest.schema.json @@ -299,7 +299,20 @@ "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 }, - "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 } + "maxPayloadBytes": { "type": "integer", "minimum": 1, "maximum": 65536 }, + "protectedRequest": { "$ref": "#/$defs/gameClientBridgeProtectedRequest" } + } + }, + "gameClientBridgeProtectedRequest": { + "type": "object", + "required": ["kind", "transportKey", "targetKey", "textField", "maxTextBytes"], + "additionalProperties": false, + "properties": { + "kind": { "enum": ["sql", "rcon", "program"] }, + "transportKey": { "$ref": "#/$defs/logicalKey" }, + "targetKey": { "$ref": "#/$defs/logicalKey" }, + "textField": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9._-]{0,79}$" }, + "maxTextBytes": { "type": "integer", "minimum": 1, "maximum": 16384 } } }, "gameClientBridgeSnapshot": { @@ -384,6 +397,9 @@ "remote.run.db.sqlite.query", "remote.run.logs.transfer", "remote.run.rcon.command", + "remote.run.protected.sql", + "remote.run.protected.rcon", + "remote.run.program.command", "client-manager.deploy", "client-manager.control", "client-manager.update", @@ -605,7 +621,7 @@ "prerequisites": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerPrerequisite" }, "maxItems": 16 }, "configMappings": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerConfigMapping" }, "minItems": 1, "maxItems": 32, "uniqueItems": true }, "discoveryMarkers": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerDiscoveryMarker" }, "minItems": 1, "maxItems": 32, "uniqueItems": true }, - "verificationChecks": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerVerificationCheck" }, "minItems": 5, "maxItems": 16, "uniqueItems": true } + "verificationChecks": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerVerificationCheck" }, "minItems": 4, "maxItems": 16, "uniqueItems": true } } }, "runtimeServerPrerequisite": { @@ -651,7 +667,7 @@ "additionalProperties": false, "properties": { "key": { "$ref": "#/$defs/logicalKey" }, - "kind": { "enum": ["file", "ftp", "rsync", "mysql", "sqlite", "rcon"] }, + "kind": { "enum": ["file", "ftp", "rsync", "mysql", "sqlite", "rcon", "program"] }, "targetKey": { "$ref": "#/$defs/logicalKey" }, "capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 } } @@ -789,7 +805,7 @@ "allOf": [ { "if": { "required": ["deployment"] }, - "then": { "required": ["version", "lifecycle", "health", "compatibility", "updatePolicy"] } + "then": { "required": ["version", "lifecycle", "health", "updatePolicy"] } } ] }, diff --git a/plugins/scripts/validate-manifest.ts b/plugins/scripts/validate-manifest.ts index a07eded..4105e13 100644 --- a/plugins/scripts/validate-manifest.ts +++ b/plugins/scripts/validate-manifest.ts @@ -393,7 +393,7 @@ function validateServerDeploymentProfiles(manifest: unknown): string[] { if (mappingKeys.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: duplicate mapping`); mappingKeys.add(mapping.fieldKey); } - const requiredChecks = new Set(["executable.present", "version.matches", "port.bound", "config.readable", "process.healthy"]); + const requiredChecks = new Set(["executable.present", "port.bound", "config.readable", "process.healthy"]); for (const check of profile.verificationChecks ?? []) { if (check.required) requiredChecks.delete(check.kind); } @@ -601,7 +601,8 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { if (typeof manifest !== "object" || manifest === null) { return []; } - type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string }; + type ProtectedRequest = { kind?: string; transportKey?: string; targetKey?: string; textField?: string; maxTextBytes?: number }; + type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string; protectedRequest?: ProtectedRequest }; type BridgeQueryTemplate = { key?: string; permission?: string; @@ -693,13 +694,44 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] { for (const [index, command] of (bridge.commands ?? []).entries()) { const location = `manifest.gameClientBridge.commands[${index}]`; const type = command.type ?? ""; - const unsafeTypeReason = unsafeGameClientBridgeCommandTypeReason(type); + const unsafeTypeReason = command.protectedRequest ? undefined : unsafeGameClientBridgeCommandTypeReason(type); if (unsafeTypeReason) { errors.push(`${location}.type: ${unsafeTypeReason}`); } if (!command.approvalLevel) { errors.push(`${location}.approvalLevel: approval metadata is required`); } + + const protectedRequest = command.protectedRequest; + if (protectedRequest) { + if (!new Set(["sql", "rcon", "program"]).has(protectedRequest.kind ?? "")) { + errors.push(`${location}.protectedRequest.kind: must be sql, rcon, or program`); + } + if (!/^[A-Za-z][A-Za-z0-9._-]{0,79}$/.test(protectedRequest.textField ?? "")) { + errors.push(`${location}.protectedRequest.textField: must be a safe bounded field name`); + } + if (!Number.isInteger(protectedRequest.maxTextBytes) || (protectedRequest.maxTextBytes ?? 0) < 1 || (protectedRequest.maxTextBytes ?? 0) > 16384) { + errors.push(`${location}.protectedRequest.maxTextBytes: must be between 1 and 16384`); + } + const transport = transportProfiles.find((candidate) => candidate.key === protectedRequest.transportKey); + if (!transport) { + errors.push(`${location}.protectedRequest.transportKey: must reference a declared runtime transport profile`); + } else { + if (!protectedRequest.targetKey || protectedRequest.targetKey !== transport.targetKey) { + errors.push(`${location}.protectedRequest.targetKey: must match the declared runtime transport target`); + } + const expectedCapability = { sql: "remote.run.protected.sql", rcon: "remote.run.protected.rcon", program: "remote.run.program.command" }[protectedRequest.kind ?? ""]; + if (protectedRequest.kind === "sql" && transport.kind !== "mysql" && transport.kind !== "sqlite") { + errors.push(`${location}.protectedRequest.transportKey: sql requests require mysql or sqlite transport`); + } + if ((protectedRequest.kind === "rcon" && transport.kind !== "rcon") || (protectedRequest.kind === "program" && transport.kind !== "program")) { + errors.push(`${location}.protectedRequest.transportKey: transport kind does not match protected request kind`); + } + if (expectedCapability && !transport.capabilities?.includes(expectedCapability)) { + errors.push(`${location}.protectedRequest.transportKey: is missing required protected transport capability`); + } + } + } for (const [field, ref] of [["payloadSchemaRef", command.payloadSchemaRef], ["resultSchemaRef", command.resultSchemaRef]] as const) { if (ref && !isSafeRelativeJsonRef(ref)) { errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`); diff --git a/plugins/sdk/bridge-contract.md b/plugins/sdk/bridge-contract.md index 30f97f0..25771bb 100644 --- a/plugins/sdk/bridge-contract.md +++ b/plugins/sdk/bridge-contract.md @@ -37,7 +37,7 @@ Run distribution, dependency, log backfill, and client-manager requests use `cre Client-manager lifecycle requests remain Platform-mediated. A plugin declaration does not grant access by itself: Platform rechecks the installed plugin, server owner/administrator scope, runtime binding, assigned Run endpoint capabilities, current distribution target/revision/key generation, and durable installation state before dispatching a typed job. -Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. Browser-facing types intentionally have no component session, component key, installation fence, host path, DSN, Run endpoint, socket, or storage credential fields. +Game-client plugin pages receive a host-provided `GameClientBridgePageClient`. The SDK defines status, command, result, snapshot, approval, and manifest declaration types but never creates its own HTTP client. Queue requests carry only a declared command type, logical profile key, bounded typed payload, expiry, priority, and idempotency key. A command may declare a protected `sql`, `rcon`, or management-program request: the plugin supplies only its one bounded text field and logical transport/target keys; Platform authorizes, approves, redacts, queues, and forwards it to Run. A management program is not host OS shell access. Browser-facing types intentionally have no component session, component key, installation fence, host path, DSN, Run endpoint, socket, or storage credential fields. Production plugin lifecycle requests use `createProductionPluginLifecycleRequest`. Envelopes contain only plugin/server scope, enumerated operation, optional target version, confirmation, and idempotency key. Platform rechecks the manifest `productionLifecycle` declaration, dependency policy, disruptive approval, endpoint capacity, compatibility, and prior idempotency inputs before dispatch. diff --git a/plugins/sdk/index.ts b/plugins/sdk/index.ts index d114158..8f04600 100644 --- a/plugins/sdk/index.ts +++ b/plugins/sdk/index.ts @@ -211,7 +211,17 @@ export interface GamePluginRemoteAccess { export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin"; export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected"; -export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "cancelled" | "expired"; +export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "unknown" | "cancelled" | "expired"; + +export type GameClientBridgeProtectedRequestKind = "sql" | "rcon" | "program"; + +export interface GameClientBridgeProtectedRequestDeclaration { + kind: GameClientBridgeProtectedRequestKind; + transportKey: string; + targetKey: string; + textField: string; + maxTextBytes: number; +} export interface GameClientBridgeCommandDeclaration { type: string; @@ -221,7 +231,8 @@ export interface GameClientBridgeCommandDeclaration { payloadSchemaRef: string; resultSchemaRef?: string; timeoutSeconds: number; - maxPayloadBytes: number; + maxPayloadBytes: number; + protectedRequest?: GameClientBridgeProtectedRequestDeclaration; } export interface GameClientBridgeSnapshotDeclaration { @@ -306,7 +317,7 @@ export interface GameClientBridgeStatus { } export interface GameClientBridgeCommandResult { - status: "succeeded" | "failed" | "cancelled"; + status: "succeeded" | "failed" | "unknown" | "cancelled"; summary?: string; payload?: Record; completedAt: string; diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 61d3183..ae85314 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -26,6 +26,7 @@ import { parseBridgeExecutionResponse, parseAIInvocationResponse, type GameClientBridgeQueryTemplateDeclaration, + type GameClientBridgeProtectedRequestDeclaration, type GameClientBridgeCompanionDeclaration, type GamePluginManifest, type RuntimeLogEventDeclaration, @@ -173,17 +174,19 @@ describe("plugin manifest validation", () => { expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]); }); - it("declares a fixed, schema-bound vehicle spawn instead of a raw command surface", () => { + it("declares bounded protected SQL and management request surfaces", () => { const pluginDir = path.join(pluginsRoot, "examples/scum-server-plugin"); - const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; resultSchemaRef?: string }>; features: Array<{ key: string; requiredHandlers?: string[] }> } }; - const command = manifest.gameClientBridge.commands.find((candidate) => candidate.type === "vehicle.spawn"); - expect(command).toBeDefined(); - expect(manifest.gameClientBridge.features.find((feature) => feature.key === "vehicle.spawn")?.requiredHandlers).toEqual(["vehicle.spawn"]); - const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, command!.payloadSchemaRef), "utf8")); - const result = JSON.parse(fs.readFileSync(path.join(pluginDir, command!.resultSchemaRef!), "utf8")); - expect(payload).toMatchObject({ additionalProperties: false, required: ["vehicleCode"], properties: { vehicleCode: { enum: ["BPC_Laika_C", "BPC_WolfsWagen_C"] } } }); - expect(JSON.stringify(payload).toLowerCase()).not.toMatch(/command|rcon|target|credential|socket|shell|sql/); - expect(result).toMatchObject({ additionalProperties: false, properties: { outcome: { enum: ["succeeded", "failed", "unknown"] } } }); + const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { gameClientBridge: { commands: Array<{ type: string; payloadSchemaRef: string; resultSchemaRef?: string; protectedRequest?: { kind: string; textField: string; transportKey: string; targetKey: string } }> } }; + const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest); + expect(commands.map((command) => command.protectedRequest?.kind)).toEqual(expect.arrayContaining(["sql", "rcon", "program"])); + for (const command of commands) { + expect(command.protectedRequest?.textField).toBe("requestText"); + expect(command.protectedRequest?.transportKey).toBe(command.protectedRequest?.targetKey); + const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, command.payloadSchemaRef), "utf8")); + const result = JSON.parse(fs.readFileSync(path.join(pluginDir, command.resultSchemaRef!), "utf8")); + expect(payload).toMatchObject({ additionalProperties: false, required: ["requestText"] }); + expect(result).toMatchObject({ additionalProperties: false, properties: { outcome: { enum: ["succeeded", "failed", "unknown"] } } }); + } }); it("declares a frozen SCUM install/adopt template with explicit mapping and verification checks", () => { @@ -191,7 +194,7 @@ describe("plugin manifest validation", () => { const template = manifest.runtimeProfiles.serverDeployments[0]; expect(template).toMatchObject({ key: "scum-steamcmd-windows", version: "1.0.0", steamAppId: "3792580", configFormat: "ini" }); expect(template.configMappings.map((mapping: any) => mapping.fieldKey)).toEqual(["serverName", "gamePort", "queryPort", "maxPlayers"]); - expect(template.verificationChecks.filter((check: any) => check.required)).toHaveLength(5); + expect(template.verificationChecks.filter((check: any) => check.required)).toHaveLength(4); }); it("rejects an SCUM template mapping an undeclared field", () => { @@ -212,18 +215,22 @@ describe("plugin manifest validation", () => { expect(unsafe.some((error) => error.includes("raw host path"))).toBe(true); }); - it("removes direct RCON, database, and DLL extension declarations", () => { + it("declares protected database and management transports without direct access", () => { const manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json"); const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { runtimeProfiles?: { lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[] }>; - transportProfiles?: Array<{ kind?: string }>; + transportProfiles?: Array<{ key?: string; kind?: string; capabilities?: string[] }>; }; }; const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local"); expect(local?.capabilities).not.toContain("remote.run.rcon.command"); expect(local?.transportKeys).not.toContain("rcon"); - expect(manifest.runtimeProfiles?.transportProfiles?.some((profile) => profile.kind === "sqlite" || profile.kind === "mysql" || profile.kind === "rcon")).toBe(false); + expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([ + expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: ["remote.run.protected.sql"] }), + expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.protected.rcon"] }), + expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] }) + ])); }); it("defines a generated SCUM companion config without inline proof or session material", () => { @@ -588,7 +595,7 @@ describe("plugin manifest validation", () => { const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { permissions: string[]; runtimeProfiles?: { - logSources?: Array<{ key: string; retentionDays?: number }>; + logSources?: Array<{ key: string; kind?: string; streamKey?: string; retentionDays?: number }>; logEvents?: Array; }; }; @@ -597,6 +604,8 @@ describe("plugin manifest validation", () => { const logEvents = manifest.runtimeProfiles?.logEvents ?? []; expect(logEvents.map((event) => event.eventType)).toEqual(expect.arrayContaining(expectedTypes)); + expect(logSources.get("scum-console-stdout")).toMatchObject({ kind: "process.stdout", streamKey: "scum.console.stdout" }); + expect(logSources.get("scum-console-stderr")).toMatchObject({ kind: "process.stderr", streamKey: "scum.console.stderr" }); expect(new Set(logEvents.map((event) => event.key)).size).toBe(logEvents.length); expect(new Set(logEvents.map((event) => event.eventType)).size).toBe(logEvents.length); for (const event of logEvents) { @@ -949,6 +958,17 @@ describe("plugin SDK", () => { expect(request).not.toHaveProperty("hostPath"); expect(request).not.toHaveProperty("dsn"); }); + + it("types protected request declarations while retaining text redaction boundaries", () => { + const declaration: GameClientBridgeProtectedRequestDeclaration = { kind: "sql", transportKey: "scum-database", targetKey: "scum-database", textField: "requestText", maxTextBytes: 4096 }; + expect(declaration).toMatchObject({ kind: "sql", textField: "requestText" }); + expect(JSON.stringify(declaration).toLowerCase()).not.toMatch(/dsn|hostpath|socket|credential|password/); + const errors = validateTemporaryBridgeManifest((manifest) => { + manifest.gameClientBridge.commands[0].type = "database.request"; + manifest.gameClientBridge.commands[0].protectedRequest = { kind: "sql", transportKey: "missing", targetKey: "missing", textField: "requestText", maxTextBytes: 512 }; + }); + expect(errors.some((error) => error.includes("protectedRequest.transportKey"))).toBe(true); + }); it("checks declared bridge permissions", () => { const context: PluginBridgeContext = { pluginId: "game.example",