refactor(scum): declare protected run requests

This commit is contained in:
npc0-hue
2026-07-29 22:37:16 +08:00
parent d7465bfd32
commit 99be8f0f3a
28 changed files with 497 additions and 152 deletions
@@ -1,29 +1,33 @@
## Design ## Design
The platform owns only reusable authorization, server isolation, auditing, The platform owns reusable authorization, tenant/server isolation, approvals,
queues, opaque storage, and channels to Run. The SCUM plugin owns its page, auditing, expiry, queues, protected storage, and channels to Run. The SCUM
allowlists, schemas, event parsers, and Companion adapters. `platform_web` plugin owns its page, request generation, schemas, event parsers, and
mounts the declared plugin page generically. Companion adapters. `platform_web` mounts the declared plugin page generically.
Run emits SCUM process stdout/stderr records through the durable log channel; A bridge command may declare a protected request transport of kind `sql`,
these are not server execution logs. The Companion parses only declared, `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 bounded record formats into semantic events. Unknown records make a bounded
diagnostic and are skipped. A per-server correlation digest may be derived diagnostic and are skipped. A per-server correlation digest may be derived
locally but never includes a raw network value in an upload. locally but never includes a raw network value in an upload.
The Companion receives only typed commands and invokes only registered typed The Companion receives only authorized, server-bound bridge commands and
ports. Its game-data port exposes allowlisted player, vehicle, and position bounded console records. Plugins, pages, and AI never receive DSNs, paths,
data as bounded projections, never DSNs, paths, credentials, or rows. Fixed credentials, raw connections, sockets, or shell access. Run results are bounded
server-management ports expose only declared operations. State changes read to `succeeded`, `failed`, or `unknown` with safe diagnostics. A request failure,
the precondition, verify a safe window, write allowed fields, then confirm the unknown text, or unsupported field affects that request alone.
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.
Runtime capability/schema probes decide whether a particular handler is Runtime capability/schema probes decide whether a particular handler is
available. They do not depend on a server/game/UE4SS/database version, build, available. They do not depend on a server/game/UE4SS/database version, build,
or source revision, and a failed probe never disables unrelated features. 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 <vehicleCode>` inside
the Companion. The text stays private to its typed transport/audit boundary.
@@ -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, platform-authorized non-production fixtures for configuration, player-state,
reward, notification, and vehicle operations; no remote server is contacted. reward, notification, and vehicle operations; no remote server is contacted.
Run's required integration boundary is a bounded stdout/stderr record stream, Run's required integration boundary is a bounded stdout/stderr console record
typed database projections, and fixed administration ports. It must not expose stream and declared protected SQL, RCON, or management-program transports.
paths, DSNs, credentials, raw rows, arbitrary SQL, shell, socket, or RCON to Plugins generate bounded request text, while Platform authorizes, approves,
the plugin, platform web, or AI. Unknown console formats create a bounded queues, redacts, and forwards it only to the bound Run request. Run alone
diagnostic and no event. 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 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 is reported unavailable; the plugin page and unrelated feature capabilities
remain active. remain active.
@@ -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_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_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_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` versioned field catalog and `game-state.patch` handler | Version discovery and verified safe window | | `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/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 | | `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 | | `contracts/scumOperations.ts`, `schemas/scumOperations.ts` | `game.scum` host branch | manifest-driven bundle contract | Generic page-bundle registry |
@@ -2,22 +2,32 @@
SCUM plugin behavior must survive server updates without treating a game, UE4SS, 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 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 ## What Changes
- Move all SCUM feature authority to the plugin and its Companion, with generic - 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. - Replace build/version/revision gates with runtime schema and capability probes.
- Let Run provide bounded SCUM stdout/stderr records, typed database reads, and - Let plugins declare and generate bounded SQL, RCON, or program-management
fixed administration operations only through platform-authorized channels. request text for a logical, server-bound protected transport. Platform stores,
- Require field allowlists, pre-read/safe-window/write-confirmation flows, and authorizes, approves, audits, expires, and forwards that opaque payload; Run
`succeeded`/`failed`/`unknown` results for mutating adapters. alone resolves the bound transport and executes the authorized request.
- Preserve fixed-template `vehicle.spawn`; its private `#spawnvehicle - Keep platform transport-agnostic: it validates declarations, scope, limits,
<vehicleCode>` audit text never enters a result or page payload. 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 ## Non-Goals
No arbitrary RCON, SQL, shell, socket, path, DSN, credential, raw database No plugin, page, AI request, or result projection receives a DSN, database
row, OCR, screenshot, keyboard/mouse injection, or desktop automation is path, raw connection, RCON credential, host path, direct socket, or shell.
introduced. No SCUM import or `game.scum` branch is added to `platform_web`. 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`.
@@ -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 - **THEN** the Companion returns a typed unavailable/failed/unknown result for
that command and does not disable an unrelated plugin feature 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 The SCUM plugin SHALL be able to generate bounded SQL, RCON, or
log channel and SHALL provide database data only as typed allowlisted program-management request text for a declared logical protected transport.
projections and fixed server-management operations. No plugin, web page, or Platform SHALL authorize, isolate by tenant and server, approve, audit with
AI request SHALL receive a path, DSN, credential, raw row, arbitrary SQL, redaction, expire, queue, store, and forward each opaque request to the bound
shell, socket, or RCON command. 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 #### Scenario: Unknown console output
- **WHEN** stdout or stderr does not match a declared semantic parser - **WHEN** stdout or stderr does not match a declared semantic parser
- **THEN** the Companion records a bounded diagnostic and uploads no semantic - **THEN** the Companion records a bounded diagnostic and uploads no semantic
event or raw line 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 <vehicleCode>` 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
@@ -4,7 +4,8 @@
The SCUM plugin SHALL own SCUM schemas, allowlists, migration adapters, The SCUM plugin SHALL own SCUM schemas, allowlists, migration adapters,
Companion behavior, and feature UI. The platform SHALL retain only reusable 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`. primitives. `platform_web` SHALL not import SCUM code or branch on `game.scum`.
#### Scenario: Page mounting #### 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 - **WHEN** no unique server-feature migration flag is present
- **THEN** historical records remain readable and plugin writes stay disabled - **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
@@ -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.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 Make handler availability server-bound and capability/schema-probe based; isolate failure to the affected command. - [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.1 Add generic SQL, RCON, and management-program protected transport declarations to the manifest schema, platform domain validation, and plugin SDK.
- [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.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 Implement state pre-read, safe-window, allowlisted write, and read-after-write confirmation with typed results. - [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 Implement frozen typed reward delivery results without automatic unknown retries. - [x] 2.4 Declare SCUM plugin protected database and management transports without adding SCUM parsing or a fixed command template.
- [x] 2.5 Preserve the fixed private vehicle-spawn template and its allowlist.
## 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.1 Update focused Go and TypeScript tests for declarations, request generation, redaction, and safe rejection.
- [x] 3.2 Keep platform records read-only with provenance and leave platform-web generic. - [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).
## 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`.
+19
View File
@@ -9,6 +9,7 @@ const (
GameClientBridgeCommandClaimed GameClientBridgeCommandState = "claimed" GameClientBridgeCommandClaimed GameClientBridgeCommandState = "claimed"
GameClientBridgeCommandSucceeded GameClientBridgeCommandState = "succeeded" GameClientBridgeCommandSucceeded GameClientBridgeCommandState = "succeeded"
GameClientBridgeCommandFailed GameClientBridgeCommandState = "failed" GameClientBridgeCommandFailed GameClientBridgeCommandState = "failed"
GameClientBridgeCommandUnknown GameClientBridgeCommandState = "unknown"
GameClientBridgeCommandCancelled GameClientBridgeCommandState = "cancelled" GameClientBridgeCommandCancelled GameClientBridgeCommandState = "cancelled"
GameClientBridgeCommandExpired GameClientBridgeCommandState = "expired" GameClientBridgeCommandExpired GameClientBridgeCommandState = "expired"
) )
@@ -39,6 +40,17 @@ type GameClientBridgeCommandDeclaration struct {
ResultSchemaRef string ResultSchemaRef string
TimeoutSeconds int TimeoutSeconds int
MaxPayloadBytes 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 { type GameClientBridgeSnapshotDeclaration struct {
@@ -108,6 +120,7 @@ type GameClientBridgeResultStatus string
const ( const (
GameClientBridgeResultSucceeded GameClientBridgeResultStatus = "succeeded" GameClientBridgeResultSucceeded GameClientBridgeResultStatus = "succeeded"
GameClientBridgeResultFailed GameClientBridgeResultStatus = "failed" GameClientBridgeResultFailed GameClientBridgeResultStatus = "failed"
GameClientBridgeResultUnknown GameClientBridgeResultStatus = "unknown"
GameClientBridgeResultCancelled GameClientBridgeResultStatus = "cancelled" GameClientBridgeResultCancelled GameClientBridgeResultStatus = "cancelled"
) )
@@ -384,6 +397,12 @@ func CopyGameClientBridgePayload(value map[string]any) map[string]any {
func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBridgeManifest { func CopyGameClientBridgeManifest(value GameClientBridgeManifest) GameClientBridgeManifest {
value.Commands = append([]GameClientBridgeCommandDeclaration(nil), value.Commands...) 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 = &copy
}
}
value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...) value.Snapshots = append([]GameClientBridgeSnapshotDeclaration(nil), value.Snapshots...)
value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...) value.QueryTemplates = append([]GameClientBridgeQueryTemplateDeclaration(nil), value.QueryTemplates...)
value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...) value.Pages = append([]GameClientBridgePageContract(nil), value.Pages...)
+3
View File
@@ -1045,6 +1045,9 @@ const (
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query" JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer" JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command" JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
JobCapabilityRemoteRunProtectedSQL = "remote.run.protected.sql"
JobCapabilityRemoteRunProtectedRCON = "remote.run.protected.rcon"
JobCapabilityRemoteRunProgram = "remote.run.program.command"
JobCapabilityRunSelfUpdate = "run.self-update" JobCapabilityRunSelfUpdate = "run.self-update"
JobCapabilityDistributionBuild = "distribution.build" JobCapabilityDistributionBuild = "distribution.build"
JobCapabilityDependenciesCheck = "dependencies.check" JobCapabilityDependenciesCheck = "dependencies.check"
+25 -2
View File
@@ -270,6 +270,15 @@ type GameClientBridgeCommandDeclarationBody struct {
ResultSchemaRef string `json:"resultSchemaRef,omitempty"` ResultSchemaRef string `json:"resultSchemaRef,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds"` TimeoutSeconds int `json:"timeoutSeconds"`
MaxPayloadBytes int `json:"maxPayloadBytes"` 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 { type GameClientBridgeSnapshotDeclarationBody struct {
@@ -1110,7 +1119,7 @@ func (remote GamePluginRemoteAccessBody) ToDomain() domain.GamePluginRemoteAcces
func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManifest { func (body GameClientBridgeManifestBody) ToDomain() domain.GameClientBridgeManifest {
commands := make([]domain.GameClientBridgeCommandDeclaration, len(body.Commands)) commands := make([]domain.GameClientBridgeCommandDeclaration, len(body.Commands))
for index, command := range 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)) snapshots := make([]domain.GameClientBridgeSnapshotDeclaration, len(body.Snapshots))
for index, snapshot := range 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} 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 { func (actions PluginLifecycleActionsBody) ToDomain() domain.PluginLifecycleActions {
return domain.PluginLifecycleActions{ return domain.PluginLifecycleActions{
Install: actions.Install, Install: actions.Install,
@@ -1527,7 +1543,7 @@ func gameClientBridgeManifestFromDomain(value domain.GameClientBridgeManifest) G
value = domain.CopyGameClientBridgeManifest(value) value = domain.CopyGameClientBridgeManifest(value)
commands := make([]GameClientBridgeCommandDeclarationBody, len(value.Commands)) commands := make([]GameClientBridgeCommandDeclarationBody, len(value.Commands))
for index, command := range 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)) snapshots := make([]GameClientBridgeSnapshotDeclarationBody, len(value.Snapshots))
for index, snapshot := range 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} 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 { func MarketplacePluginListFromDomain(plugins []domain.PluginMarketplacePlugin) MarketplacePluginListResponse {
items := make([]MarketplacePluginResponse, len(plugins)) items := make([]MarketplacePluginResponse, len(plugins))
for i, plugin := range plugins { for i, plugin := range plugins {
+36 -2
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"crypto/sha256"
"encoding/json" "encoding/json"
"fmt" "fmt"
"reflect" "reflect"
@@ -251,6 +252,30 @@ func gameClientBridgeQueryTemplateKeys(declarations []domain.GameClientBridgeQue
return values 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) { func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request domain.GameClientBridgeQueueRequest) (domain.GameClientBridgeCommand, error) {
request.Payload = domain.CopyGameClientBridgePayload(request.Payload) request.Payload = domain.CopyGameClientBridgePayload(request.Payload)
if err := validator.ValidateGameClientBridgeQueueRequest(request); err != nil { 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)) { if request.ExpiresAt.After(stamp.Add(time.Duration(declaration.TimeoutSeconds) * time.Second)) {
return domain.GameClientBridgeCommand{}, validationError("bridge command expiry exceeds declared timeout") 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) existing, err := svc.store.GameClientBridgeCommands().GetByIdempotency(request.ServerInstanceID, requesterID, request.CommandType, request.IdempotencyKey)
if err == nil { if err == nil {
@@ -312,7 +340,11 @@ func (svc *CoreService) queueGameClientBridgeCommand(requesterID string, request
CreatedAt: stamp, CreatedAt: stamp,
UpdatedAt: 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 { if err != nil {
return domain.GameClientBridgeCommand{}, err return domain.GameClientBridgeCommand{}, err
} }
@@ -426,6 +458,8 @@ func (svc *CoreService) completeGameClientBridgeCommand(component gameClientBrid
command.State = domain.GameClientBridgeCommandSucceeded command.State = domain.GameClientBridgeCommandSucceeded
case domain.GameClientBridgeResultFailed: case domain.GameClientBridgeResultFailed:
command.State = domain.GameClientBridgeCommandFailed command.State = domain.GameClientBridgeCommandFailed
case domain.GameClientBridgeResultUnknown:
command.State = domain.GameClientBridgeCommandUnknown
case domain.GameClientBridgeResultCancelled: case domain.GameClientBridgeResultCancelled:
command.State = domain.GameClientBridgeCommandCancelled command.State = domain.GameClientBridgeCommandCancelled
} }
@@ -684,7 +718,7 @@ func gameClientBridgeClaimMatches(command domain.GameClientBridgeCommand, compon
func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool { func isTerminalGameClientBridgeCommandState(state domain.GameClientBridgeCommandState) bool {
switch state { switch state {
case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired: case domain.GameClientBridgeCommandSucceeded, domain.GameClientBridgeCommandFailed, domain.GameClientBridgeCommandUnknown, domain.GameClientBridgeCommandCancelled, domain.GameClientBridgeCommandExpired:
return true return true
default: default:
return false return false
@@ -1,6 +1,7 @@
package service package service
import ( import (
"strings"
"testing" "testing"
"time" "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) { func TestGameClientBridgeIdempotencyScopeIsAppliedByService(t *testing.T) {
svc, clock := newGameClientBridgeService(t) svc, clock := newGameClientBridgeService(t)
request := bridgeQueueRequest(*clock, "scope-key") request := bridgeQueueRequest(*clock, "scope-key")
+1 -1
View File
@@ -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", 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"}}, 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}}, 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}},
}}}, }}},
} }
} }
+1 -1
View File
@@ -72,7 +72,7 @@ func ValidateGameClientBridgeResultRequest(request domain.GameClientBridgeResult
if request.FencingToken == 0 { if request.FencingToken == 0 {
violations = append(violations, "fencingToken is required") 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 = append(violations, "status is invalid")
} }
violations = appendGameClientBridgeText(violations, "summary", request.Summary, 512) violations = appendGameClientBridgeText(violations, "summary", request.Summary, 512)
@@ -52,7 +52,7 @@ func TestValidateGameClientBridgeRequestFieldBounds(t *testing.T) {
{name: "claim token", err: ValidateGameClientBridgeClaimRequest(domain.GameClientBridgeClaimRequest{SessionToken: " session"}), want: "sessionToken"}, {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: "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: "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: "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: "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"}, {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"},
+53 -6
View File
@@ -420,10 +420,14 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
violations = append(violations, prefix+".profileKey must reference a declared Client Manager profile") 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{}{} commandTypes := map[string]struct{}{}
for index, command := range bridge.Commands { for index, command := range bridge.Commands {
prefix := fmt.Sprintf("%s.commands[%d]", field, index) 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") violations = append(violations, prefix+".type is invalid or unsafe")
} }
if _, exists := commandTypes[command.Type]; exists { if _, exists := commandTypes[command.Type]; exists {
@@ -448,6 +452,7 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize { if command.MaxPayloadBytes <= 0 || command.MaxPayloadBytes > maxGameClientBridgePayloadSize {
violations = append(violations, prefix+".maxPayloadBytes is invalid") violations = append(violations, prefix+".maxPayloadBytes is invalid")
} }
violations = append(violations, validateGameClientBridgeProtectedRequest(prefix+".protectedRequest", command.ProtectedRequest, transports)...)
} }
snapshotTypes := map[string]struct{}{} snapshotTypes := map[string]struct{}{}
for index, snapshot := range bridge.Snapshots { for index, snapshot := range bridge.Snapshots {
@@ -468,10 +473,6 @@ func validateGameClientBridgeManifest(field string, bridge domain.GameClientBrid
} }
} }
queryTemplates := map[string]domain.GameClientBridgeQueryTemplateDeclaration{} 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 { for index, template := range bridge.QueryTemplates {
prefix := fmt.Sprintf("%s.queryTemplates[%d]", field, index) prefix := fmt.Sprintf("%s.queryTemplates[%d]", field, index)
if !clientManagerIdentifierPattern.MatchString(template.Key) { 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") 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 { func ValidatePluginBridgeAuthorizeRequest(request domain.PluginBridgeAuthorizeRequest) error {
var violations []string var violations []string
violations = appendRequired(violations, "pluginId", request.PluginID) violations = appendRequired(violations, "pluginId", request.PluginID)
@@ -1902,6 +1947,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop, domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
domain.JobCapabilityRemoteRunProtectedSQL, domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd, domain.JobCapabilityDeploymentPlan, domain.JobCapabilitySCUMDeploymentPlan, domain.JobCapabilityDeploymentShellPosix, domain.JobCapabilityDeploymentShellPowerShell, domain.JobCapabilityDeploymentShellCmd,
domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate,
@@ -1934,7 +1980,8 @@ func remoteCapabilityRequiresInputRef(capability string) bool {
domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLQuery,
domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunRCONCommand: domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProtectedSQL,
domain.JobCapabilityRemoteRunProtectedRCON, domain.JobCapabilityRemoteRunProgram:
return true return true
default: default:
return false return false
+2 -2
View File
@@ -296,7 +296,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i) prefix := fmt.Sprintf("runtimeProfiles.transportProfiles[%d]", i)
violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...) violations = append(violations, validateProfileKey(prefix+".key", transport.Key)...)
violations = append(violations, recordRuntimeProfileKey(transportKeys, 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") violations = append(violations, prefix+".kind is invalid")
} }
if transport.TargetKey != "" { if transport.TargetKey != "" {
@@ -480,7 +480,7 @@ func ValidateGamePluginRuntimeProfiles(profiles domain.GamePluginRuntimeProfiles
} }
func containsRequiredVerification(checks []domain.RuntimeServerVerificationCheck) bool { 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 { for _, check := range checks {
if check.Required { if check.Required {
if _, ok := required[check.Kind]; ok { if _, ok := required[check.Kind]; ok {
@@ -5,12 +5,13 @@ feature gate: no SCUM game, database, UE4SS build, or source revision controls
plugin availability. plugin availability.
The Companion declares availability from its server-bound typed ports and The Companion declares availability from its server-bound typed ports and
runtime schema probes. Notification and fixed vehicle spawning can use a local runtime schema probes. Plugin-generated SQL, RCON, and management-program
typed transport, but callers never supply a command, socket, credential, path, request text is declared through the protected Platform-to-Run transport, not
or raw transport reply. Vehicle spawning creates only the private executed by the Companion. Callers never receive a socket, credential, path,
`#spawnvehicle <vehicleCode>` template from the plugin allowlist. 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 Semantic events come from bounded Run stdout/stderr console records. Unknown
create diagnostics and never produce fabricated events. Run database access is records create diagnostics and never produce fabricated events. DSNs, rows,
limited to typed allowlisted projections and safe mutations; DSNs, rows, SQL, connections, and credentials do not leave Run; request text is protected and
and credentials do not leave Run. redacted from browser and audit projections.
@@ -68,7 +68,7 @@ func TestRuntimeAdapterUsesOnlyLogicalConfigValuesAndRedactsDiagnostics(t *testi
} }
} }
func TestVersionedUE4SSNotificationIsFixedTypedAndRedacted(t *testing.T) { func TestUE4SSNotificationIsTypedAndRedacted(t *testing.T) {
port := &notificationPortFixture{accepted: true} port := &notificationPortFixture{accepted: true}
adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port} adapter := RuntimeAdapter{BoundServerID: "server-1", Notification: port}
result, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "76561198000000001", "message": "Moon \"gift\""}) result, err := adapter.NotifyPlayer(context.Background(), map[string]any{"playerId": "76561198000000001", "message": "Moon \"gift\""})
@@ -10,7 +10,8 @@ import (
) )
// SafeAdapter is intentionally narrow: it receives typed values only and has // 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 { type SafeAdapter interface {
ReadConfiguration(context.Context) (map[string]any, error) ReadConfiguration(context.Context) (map[string]any, error)
PatchConfiguration(context.Context, map[string]any) (map[string]any, error) PatchConfiguration(context.Context, map[string]any) (map[string]any, error)
@@ -47,6 +47,9 @@
"remote.run.process.start", "remote.run.process.start",
"remote.run.process.stop", "remote.run.process.stop",
"remote.run.logs.transfer", "remote.run.logs.transfer",
"remote.run.protected.sql",
"remote.run.protected.rcon",
"remote.run.program.command",
"client-manager.deploy", "client-manager.deploy",
"client-manager.control", "client-manager.control",
"client-manager.update", "client-manager.update",
@@ -67,7 +70,10 @@
"remote.run.files.write", "remote.run.files.write",
"remote.run.process.start", "remote.run.process.start",
"remote.run.process.stop", "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 "logTransfer": true
}, },
@@ -190,6 +196,39 @@
"resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json", "resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json",
"timeoutSeconds": 120, "timeoutSeconds": 120,
"maxPayloadBytes": 4096 "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": [ "snapshots": [
@@ -271,7 +310,10 @@
"vehicle.spawn", "vehicle.spawn",
"event.start", "event.start",
"restart.prepare", "restart.prepare",
"maintenance.prepare" "maintenance.prepare",
"database.request",
"management.rcon.request",
"management.program.request"
], ],
"snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"], "snapshotTypes": ["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"],
"featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "vehicle.spawn", "trajectory.collect"] "featureKeys": ["config.manage", "player.intelligence", "reward.delivery", "state.patch", "vehicle.spawn", "trajectory.collect"]
@@ -482,7 +524,6 @@
], ],
"discoveryMarkers": [ "discoveryMarkers": [
{ "key": "scum-executable", "kind": "file.exists", "targetKey": "scum/server-executable", "required": true }, { "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-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-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 }, { "key": "scum-game-port", "kind": "port.open", "targetKey": "game-port", "required": true },
@@ -490,7 +531,6 @@
], ],
"verificationChecks": [ "verificationChecks": [
{ "key": "executable", "kind": "executable.present", "targetKey": "scum/server-executable", "required": true }, { "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": "game-port", "kind": "port.bound", "targetKey": "game-port", "required": true },
{ "key": "config", "kind": "config.readable", "targetKey": "scum/server-settings", "required": true }, { "key": "config", "kind": "config.readable", "targetKey": "scum/server-settings", "required": true },
{ "key": "process", "kind": "process.healthy", "targetKey": "scum/server-executable", "required": true } { "key": "process", "kind": "process.healthy", "targetKey": "scum/server-executable", "required": true }
@@ -498,6 +538,22 @@
} }
], ],
"logSources": [ "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", "key": "scum-chat-events",
"kind": "file.tail", "kind": "file.tail",
@@ -686,6 +742,24 @@
"remote.rsync.read", "remote.rsync.read",
"remote.rsync.write" "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": [ "clientManagers": [
@@ -742,10 +816,6 @@
"offlineAfterSeconds": 120, "offlineAfterSeconds": 120,
"requiredCapabilities": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"] "requiredCapabilities": ["component.register", "component.heartbeat", "component.health", "component.control", "game-client.bridge", "logs.stream"]
}, },
"compatibility": {
"minimumVersion": "1.0.0",
"allowDowngrade": false
},
"updatePolicy": { "updatePolicy": {
"strategy": "manual-staged", "strategy": "manual-staged",
"requireApproval": true, "requireApproval": true,
@@ -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 } }
}
@@ -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 } }
}
@@ -299,7 +299,20 @@
"payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "payloadSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" }, "resultSchemaRef": { "$ref": "#/$defs/relativeJsonRef" },
"timeoutSeconds": { "type": "integer", "minimum": 1, "maximum": 3600 }, "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": { "gameClientBridgeSnapshot": {
@@ -384,6 +397,9 @@
"remote.run.db.sqlite.query", "remote.run.db.sqlite.query",
"remote.run.logs.transfer", "remote.run.logs.transfer",
"remote.run.rcon.command", "remote.run.rcon.command",
"remote.run.protected.sql",
"remote.run.protected.rcon",
"remote.run.program.command",
"client-manager.deploy", "client-manager.deploy",
"client-manager.control", "client-manager.control",
"client-manager.update", "client-manager.update",
@@ -605,7 +621,7 @@
"prerequisites": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerPrerequisite" }, "maxItems": 16 }, "prerequisites": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerPrerequisite" }, "maxItems": 16 },
"configMappings": { "type": "array", "items": { "$ref": "#/$defs/runtimeServerConfigMapping" }, "minItems": 1, "maxItems": 32, "uniqueItems": true }, "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 }, "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": { "runtimeServerPrerequisite": {
@@ -651,7 +667,7 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"key": { "$ref": "#/$defs/logicalKey" }, "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" }, "targetKey": { "$ref": "#/$defs/logicalKey" },
"capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 } "capabilities": { "type": "array", "items": { "$ref": "#/$defs/runCapability" }, "uniqueItems": true, "minItems": 1 }
} }
@@ -789,7 +805,7 @@
"allOf": [ "allOf": [
{ {
"if": { "required": ["deployment"] }, "if": { "required": ["deployment"] },
"then": { "required": ["version", "lifecycle", "health", "compatibility", "updatePolicy"] } "then": { "required": ["version", "lifecycle", "health", "updatePolicy"] }
} }
] ]
}, },
+35 -3
View File
@@ -393,7 +393,7 @@ function validateServerDeploymentProfiles(manifest: unknown): string[] {
if (mappingKeys.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: duplicate mapping`); if (mappingKeys.has(mapping.fieldKey)) errors.push(`${mappingLocation}.fieldKey: duplicate mapping`);
mappingKeys.add(mapping.fieldKey); 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 ?? []) { for (const check of profile.verificationChecks ?? []) {
if (check.required) requiredChecks.delete(check.kind); if (check.required) requiredChecks.delete(check.kind);
} }
@@ -601,7 +601,8 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
if (typeof manifest !== "object" || manifest === null) { if (typeof manifest !== "object" || manifest === null) {
return []; 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 = { type BridgeQueryTemplate = {
key?: string; key?: string;
permission?: string; permission?: string;
@@ -693,13 +694,44 @@ export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
for (const [index, command] of (bridge.commands ?? []).entries()) { for (const [index, command] of (bridge.commands ?? []).entries()) {
const location = `manifest.gameClientBridge.commands[${index}]`; const location = `manifest.gameClientBridge.commands[${index}]`;
const type = command.type ?? ""; const type = command.type ?? "";
const unsafeTypeReason = unsafeGameClientBridgeCommandTypeReason(type); const unsafeTypeReason = command.protectedRequest ? undefined : unsafeGameClientBridgeCommandTypeReason(type);
if (unsafeTypeReason) { if (unsafeTypeReason) {
errors.push(`${location}.type: ${unsafeTypeReason}`); errors.push(`${location}.type: ${unsafeTypeReason}`);
} }
if (!command.approvalLevel) { if (!command.approvalLevel) {
errors.push(`${location}.approvalLevel: approval metadata is required`); 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) { for (const [field, ref] of [["payloadSchemaRef", command.payloadSchemaRef], ["resultSchemaRef", command.resultSchemaRef]] as const) {
if (ref && !isSafeRelativeJsonRef(ref)) { if (ref && !isSafeRelativeJsonRef(ref)) {
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`); errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
+1 -1
View File
@@ -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. 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. 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.
+13 -2
View File
@@ -211,7 +211,17 @@ export interface GamePluginRemoteAccess {
export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin"; export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin";
export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected"; 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 { export interface GameClientBridgeCommandDeclaration {
type: string; type: string;
@@ -222,6 +232,7 @@ export interface GameClientBridgeCommandDeclaration {
resultSchemaRef?: string; resultSchemaRef?: string;
timeoutSeconds: number; timeoutSeconds: number;
maxPayloadBytes: number; maxPayloadBytes: number;
protectedRequest?: GameClientBridgeProtectedRequestDeclaration;
} }
export interface GameClientBridgeSnapshotDeclaration { export interface GameClientBridgeSnapshotDeclaration {
@@ -306,7 +317,7 @@ export interface GameClientBridgeStatus {
} }
export interface GameClientBridgeCommandResult { export interface GameClientBridgeCommandResult {
status: "succeeded" | "failed" | "cancelled"; status: "succeeded" | "failed" | "unknown" | "cancelled";
summary?: string; summary?: string;
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
completedAt: string; completedAt: string;
+34 -14
View File
@@ -26,6 +26,7 @@ import {
parseBridgeExecutionResponse, parseBridgeExecutionResponse,
parseAIInvocationResponse, parseAIInvocationResponse,
type GameClientBridgeQueryTemplateDeclaration, type GameClientBridgeQueryTemplateDeclaration,
type GameClientBridgeProtectedRequestDeclaration,
type GameClientBridgeCompanionDeclaration, type GameClientBridgeCompanionDeclaration,
type GamePluginManifest, type GamePluginManifest,
type RuntimeLogEventDeclaration, type RuntimeLogEventDeclaration,
@@ -173,17 +174,19 @@ describe("plugin manifest validation", () => {
expect(validateManifestFile("examples/scum-server-plugin/manifest.json")).toEqual([]); 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 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 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 command = manifest.gameClientBridge.commands.find((candidate) => candidate.type === "vehicle.spawn"); const commands = manifest.gameClientBridge.commands.filter((candidate) => candidate.protectedRequest);
expect(command).toBeDefined(); expect(commands.map((command) => command.protectedRequest?.kind)).toEqual(expect.arrayContaining(["sql", "rcon", "program"]));
expect(manifest.gameClientBridge.features.find((feature) => feature.key === "vehicle.spawn")?.requiredHandlers).toEqual(["vehicle.spawn"]); for (const command of commands) {
const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, command!.payloadSchemaRef), "utf8")); expect(command.protectedRequest?.textField).toBe("requestText");
const result = JSON.parse(fs.readFileSync(path.join(pluginDir, command!.resultSchemaRef!), "utf8")); expect(command.protectedRequest?.transportKey).toBe(command.protectedRequest?.targetKey);
expect(payload).toMatchObject({ additionalProperties: false, required: ["vehicleCode"], properties: { vehicleCode: { enum: ["BPC_Laika_C", "BPC_WolfsWagen_C"] } } }); const payload = JSON.parse(fs.readFileSync(path.join(pluginDir, command.payloadSchemaRef), "utf8"));
expect(JSON.stringify(payload).toLowerCase()).not.toMatch(/command|rcon|target|credential|socket|shell|sql/); 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"] } } }); 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", () => { 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]; const template = manifest.runtimeProfiles.serverDeployments[0];
expect(template).toMatchObject({ key: "scum-steamcmd-windows", version: "1.0.0", steamAppId: "3792580", configFormat: "ini" }); 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.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", () => { 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); 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 manifestPath = path.join(pluginsRoot, "examples/scum-server-plugin/manifest.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as {
runtimeProfiles?: { runtimeProfiles?: {
lifecycleProfiles?: Array<{ key: string; capabilities?: string[]; transportKeys?: string[] }>; 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"); const local = manifest.runtimeProfiles?.lifecycleProfiles?.find((profile) => profile.key === "run-local");
expect(local?.capabilities).not.toContain("remote.run.rcon.command"); expect(local?.capabilities).not.toContain("remote.run.rcon.command");
expect(local?.transportKeys).not.toContain("rcon"); 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", () => { 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 { const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
permissions: string[]; permissions: string[];
runtimeProfiles?: { runtimeProfiles?: {
logSources?: Array<{ key: string; retentionDays?: number }>; logSources?: Array<{ key: string; kind?: string; streamKey?: string; retentionDays?: number }>;
logEvents?: Array<RuntimeLogEventDeclaration>; logEvents?: Array<RuntimeLogEventDeclaration>;
}; };
}; };
@@ -597,6 +604,8 @@ describe("plugin manifest validation", () => {
const logEvents = manifest.runtimeProfiles?.logEvents ?? []; const logEvents = manifest.runtimeProfiles?.logEvents ?? [];
expect(logEvents.map((event) => event.eventType)).toEqual(expect.arrayContaining(expectedTypes)); 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.key)).size).toBe(logEvents.length);
expect(new Set(logEvents.map((event) => event.eventType)).size).toBe(logEvents.length); expect(new Set(logEvents.map((event) => event.eventType)).size).toBe(logEvents.length);
for (const event of logEvents) { for (const event of logEvents) {
@@ -949,6 +958,17 @@ describe("plugin SDK", () => {
expect(request).not.toHaveProperty("hostPath"); expect(request).not.toHaveProperty("hostPath");
expect(request).not.toHaveProperty("dsn"); 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", () => { it("checks declared bridge permissions", () => {
const context: PluginBridgeContext = { const context: PluginBridgeContext = {
pluginId: "game.example", pluginId: "game.example",