@holoscript/game-security-plugin 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/__tests__/securitysolver.test.d.ts +1 -0
- package/dist/__tests__/securitysolver.test.js +123 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +41 -0
- package/dist/securitysolver.d.ts +137 -0
- package/dist/securitysolver.js +348 -0
- package/package.json +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 HoloScript Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const securitysolver_1 = require("../securitysolver");
|
|
5
|
+
function baseState(overrides = {}) {
|
|
6
|
+
return {
|
|
7
|
+
tick: 100,
|
|
8
|
+
players: {
|
|
9
|
+
player_1: {
|
|
10
|
+
playerId: 'player_1',
|
|
11
|
+
tick: 99,
|
|
12
|
+
position: { x: 0, y: 0, z: 0 },
|
|
13
|
+
velocity: { x: 0, y: 0, z: 0 },
|
|
14
|
+
resources: { ammo: 4, coins: 10 },
|
|
15
|
+
cooldowns: { fire: 104 },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function action(overrides = {}) {
|
|
22
|
+
return {
|
|
23
|
+
actionId: 'act_1',
|
|
24
|
+
playerId: 'player_1',
|
|
25
|
+
type: 'move',
|
|
26
|
+
tick: 100,
|
|
27
|
+
payload: { position: { x: 1, y: 0, z: 0 } },
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
(0, vitest_1.describe)('AuthoritativeActionGuard', () => {
|
|
32
|
+
(0, vitest_1.it)('accepts plausible movement into authoritative state', () => {
|
|
33
|
+
const guard = new securitysolver_1.AuthoritativeActionGuard({
|
|
34
|
+
rules: [(0, securitysolver_1.createMovementEnvelopeRule)({ maxMetersPerSecond: 80 })],
|
|
35
|
+
tickRateHz: 60,
|
|
36
|
+
});
|
|
37
|
+
const decision = guard.evaluate(action(), baseState());
|
|
38
|
+
(0, vitest_1.expect)(decision.stateAccepted).toBe(true);
|
|
39
|
+
(0, vitest_1.expect)(decision.disposition).toBe('accept');
|
|
40
|
+
(0, vitest_1.expect)(decision.violations).toHaveLength(0);
|
|
41
|
+
(0, vitest_1.expect)(decision.replayKey).toContain('game-sec:player_1:100');
|
|
42
|
+
});
|
|
43
|
+
(0, vitest_1.it)('rejects impossible movement before it becomes authoritative state', () => {
|
|
44
|
+
const guard = new securitysolver_1.AuthoritativeActionGuard({
|
|
45
|
+
rules: [(0, securitysolver_1.createMovementEnvelopeRule)({ maxMetersPerSecond: 8 })],
|
|
46
|
+
tickRateHz: 60,
|
|
47
|
+
});
|
|
48
|
+
const decision = guard.evaluate(action({ payload: { position: { x: 50, y: 0, z: 0 } } }), baseState());
|
|
49
|
+
(0, vitest_1.expect)(decision.stateAccepted).toBe(false);
|
|
50
|
+
(0, vitest_1.expect)(decision.disposition).toBe('reject');
|
|
51
|
+
(0, vitest_1.expect)(decision.violations.map((violation) => violation.ruleId)).toContain('movement.max_speed');
|
|
52
|
+
});
|
|
53
|
+
(0, vitest_1.it)('rejects duplicate action ids as replay attempts', () => {
|
|
54
|
+
const guard = new securitysolver_1.AuthoritativeActionGuard();
|
|
55
|
+
const first = guard.evaluate(action(), baseState());
|
|
56
|
+
const second = guard.evaluate(action(), baseState());
|
|
57
|
+
(0, vitest_1.expect)(first.stateAccepted).toBe(true);
|
|
58
|
+
(0, vitest_1.expect)(second.stateAccepted).toBe(false);
|
|
59
|
+
(0, vitest_1.expect)(second.violations[0].ruleId).toBe('protocol.unique_action_id');
|
|
60
|
+
});
|
|
61
|
+
(0, vitest_1.it)('rejects cooldown bypass attempts', () => {
|
|
62
|
+
const guard = new securitysolver_1.AuthoritativeActionGuard({
|
|
63
|
+
rules: [(0, securitysolver_1.createCooldownRule)({ fire: 5 })],
|
|
64
|
+
});
|
|
65
|
+
const decision = guard.evaluate(action({ actionId: 'act_fire', type: 'fire', payload: {} }), baseState());
|
|
66
|
+
(0, vitest_1.expect)(decision.stateAccepted).toBe(false);
|
|
67
|
+
(0, vitest_1.expect)(decision.violations[0].ruleId).toBe('action.cooldown');
|
|
68
|
+
});
|
|
69
|
+
(0, vitest_1.it)('rejects resource spends above authoritative balance', () => {
|
|
70
|
+
const guard = new securitysolver_1.AuthoritativeActionGuard({
|
|
71
|
+
rules: [(0, securitysolver_1.createResourceSpendRule)()],
|
|
72
|
+
});
|
|
73
|
+
const decision = guard.evaluate(action({ actionId: 'act_buy', type: 'buy', payload: { cost: { coins: 12 } } }), baseState());
|
|
74
|
+
(0, vitest_1.expect)(decision.stateAccepted).toBe(false);
|
|
75
|
+
(0, vitest_1.expect)(decision.violations[0].ruleId).toBe('resource.insufficient_balance');
|
|
76
|
+
});
|
|
77
|
+
(0, vitest_1.it)('lets AI watchdog quarantine high-risk but technically valid actions', () => {
|
|
78
|
+
const guard = new securitysolver_1.AuthoritativeActionGuard();
|
|
79
|
+
const decision = guard.evaluate(action(), baseState(), [
|
|
80
|
+
{
|
|
81
|
+
label: 'synthetic_input_timing',
|
|
82
|
+
score: 0.82,
|
|
83
|
+
reasons: ['input cadence matched automation cluster'],
|
|
84
|
+
evidenceRef: 'watchdog/run-17',
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
(0, vitest_1.expect)(decision.stateAccepted).toBe(false);
|
|
88
|
+
(0, vitest_1.expect)(decision.disposition).toBe('quarantine');
|
|
89
|
+
(0, vitest_1.expect)(decision.watchdog.reasons).toContain('input cadence matched automation cluster');
|
|
90
|
+
});
|
|
91
|
+
(0, vitest_1.it)('keeps lower-confidence AI watchdog findings reviewable without blocking state', () => {
|
|
92
|
+
const guard = new securitysolver_1.AuthoritativeActionGuard();
|
|
93
|
+
const decision = guard.evaluate(action(), baseState(), [
|
|
94
|
+
{
|
|
95
|
+
label: 'edge_tracking',
|
|
96
|
+
score: 0.61,
|
|
97
|
+
reasons: ['player stayed near max speed envelope'],
|
|
98
|
+
},
|
|
99
|
+
]);
|
|
100
|
+
(0, vitest_1.expect)(decision.stateAccepted).toBe(true);
|
|
101
|
+
(0, vitest_1.expect)(decision.disposition).toBe('review');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
(0, vitest_1.describe)('game security evidence', () => {
|
|
105
|
+
(0, vitest_1.it)('creates deterministic hashes for equivalent evidence payloads', () => {
|
|
106
|
+
const a = (0, securitysolver_1.stableHash)({ z: 1, a: { b: true } });
|
|
107
|
+
const b = (0, securitysolver_1.stableHash)({ a: { b: true }, z: 1 });
|
|
108
|
+
(0, vitest_1.expect)(a).toBe(b);
|
|
109
|
+
(0, vitest_1.expect)(a).toMatch(/^sha256:/);
|
|
110
|
+
});
|
|
111
|
+
(0, vitest_1.it)('builds a replayable receipt for rejected action evidence', () => {
|
|
112
|
+
const guard = new securitysolver_1.AuthoritativeActionGuard({
|
|
113
|
+
rules: [(0, securitysolver_1.createMovementEnvelopeRule)({ maxMetersPerSecond: 8 })],
|
|
114
|
+
});
|
|
115
|
+
const decision = guard.evaluate(action({ payload: { position: { x: 50, y: 0, z: 0 } } }), baseState());
|
|
116
|
+
const receipt = (0, securitysolver_1.buildGameSecurityReceipt)(decision, { runId: 'match-7' });
|
|
117
|
+
(0, vitest_1.expect)(receipt.schema).toBe('holoscript.game-security.receipt.v0.1.0');
|
|
118
|
+
(0, vitest_1.expect)(receipt.runId).toBe('match-7');
|
|
119
|
+
(0, vitest_1.expect)(receipt.acceptance.accepted).toBe(false);
|
|
120
|
+
(0, vitest_1.expect)(receipt.acceptance.violations[0].criterion).toBe('movement.max_speed');
|
|
121
|
+
(0, vitest_1.expect)(receipt.payloadHash).toMatch(/^sha256:/);
|
|
122
|
+
});
|
|
123
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export * from './securitysolver';
|
|
2
|
+
export declare const GAME_SECURITY_KEYWORDS: readonly [{
|
|
3
|
+
readonly term: "anti cheat";
|
|
4
|
+
readonly traits: readonly ["authoritative_action_guard", "ai_watchdog"];
|
|
5
|
+
readonly spatialRole: "security";
|
|
6
|
+
}, {
|
|
7
|
+
readonly term: "server authoritative game";
|
|
8
|
+
readonly traits: readonly ["authoritative_action_guard"];
|
|
9
|
+
readonly spatialRole: "authority";
|
|
10
|
+
}, {
|
|
11
|
+
readonly term: "replay evidence";
|
|
12
|
+
readonly traits: readonly ["authoritative_action_guard", "replay_evidence"];
|
|
13
|
+
readonly spatialRole: "evidence";
|
|
14
|
+
}, {
|
|
15
|
+
readonly term: "ai watchdog";
|
|
16
|
+
readonly traits: readonly ["ai_watchdog"];
|
|
17
|
+
readonly spatialRole: "monitor";
|
|
18
|
+
}];
|
|
19
|
+
export declare const VERSION = "0.1.0";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.VERSION = exports.GAME_SECURITY_KEYWORDS = void 0;
|
|
18
|
+
__exportStar(require("./securitysolver"), exports);
|
|
19
|
+
exports.GAME_SECURITY_KEYWORDS = [
|
|
20
|
+
{
|
|
21
|
+
term: 'anti cheat',
|
|
22
|
+
traits: ['authoritative_action_guard', 'ai_watchdog'],
|
|
23
|
+
spatialRole: 'security',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
term: 'server authoritative game',
|
|
27
|
+
traits: ['authoritative_action_guard'],
|
|
28
|
+
spatialRole: 'authority',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
term: 'replay evidence',
|
|
32
|
+
traits: ['authoritative_action_guard', 'replay_evidence'],
|
|
33
|
+
spatialRole: 'evidence',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
term: 'ai watchdog',
|
|
37
|
+
traits: ['ai_watchdog'],
|
|
38
|
+
spatialRole: 'monitor',
|
|
39
|
+
},
|
|
40
|
+
];
|
|
41
|
+
exports.VERSION = '0.1.0';
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
export type JsonPrimitive = string | number | boolean | null;
|
|
2
|
+
export type JsonValue = JsonPrimitive | JsonValue[] | {
|
|
3
|
+
[key: string]: JsonValue;
|
|
4
|
+
};
|
|
5
|
+
export type JsonRecord = {
|
|
6
|
+
[key: string]: JsonValue;
|
|
7
|
+
};
|
|
8
|
+
export interface Vector3 {
|
|
9
|
+
x: number;
|
|
10
|
+
y: number;
|
|
11
|
+
z: number;
|
|
12
|
+
}
|
|
13
|
+
export interface PlayerSecuritySnapshot {
|
|
14
|
+
playerId: string;
|
|
15
|
+
tick: number;
|
|
16
|
+
position?: Vector3;
|
|
17
|
+
velocity?: Vector3;
|
|
18
|
+
resources?: Record<string, number>;
|
|
19
|
+
cooldowns?: Record<string, number>;
|
|
20
|
+
}
|
|
21
|
+
export interface AuthoritativeWorldState {
|
|
22
|
+
tick: number;
|
|
23
|
+
players: Record<string, PlayerSecuritySnapshot>;
|
|
24
|
+
acceptedActionIds?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
export interface ProposedGameAction {
|
|
27
|
+
actionId: string;
|
|
28
|
+
playerId: string;
|
|
29
|
+
type: string;
|
|
30
|
+
tick: number;
|
|
31
|
+
payload: JsonRecord;
|
|
32
|
+
clientTimeMs?: number;
|
|
33
|
+
signature?: string;
|
|
34
|
+
}
|
|
35
|
+
export type GuardSeverity = 'info' | 'warning' | 'error';
|
|
36
|
+
export type GuardDisposition = 'accept' | 'review' | 'reject' | 'quarantine' | 'kick' | 'ban';
|
|
37
|
+
export interface GuardViolation {
|
|
38
|
+
ruleId: string;
|
|
39
|
+
severity: GuardSeverity;
|
|
40
|
+
message: string;
|
|
41
|
+
evidence: JsonRecord;
|
|
42
|
+
}
|
|
43
|
+
export interface RuleEvaluationContext {
|
|
44
|
+
nowTick: number;
|
|
45
|
+
tickRateHz: number;
|
|
46
|
+
maxLagTicks: number;
|
|
47
|
+
}
|
|
48
|
+
export interface AuthoritativeRule {
|
|
49
|
+
id: string;
|
|
50
|
+
description: string;
|
|
51
|
+
evaluate(action: ProposedGameAction, state: AuthoritativeWorldState, context: RuleEvaluationContext): GuardViolation[];
|
|
52
|
+
}
|
|
53
|
+
export interface AiWatchdogSignal {
|
|
54
|
+
label: string;
|
|
55
|
+
score: number;
|
|
56
|
+
reasons: string[];
|
|
57
|
+
evidenceRef?: string;
|
|
58
|
+
}
|
|
59
|
+
export interface WatchdogThresholds {
|
|
60
|
+
review: number;
|
|
61
|
+
quarantine: number;
|
|
62
|
+
kick: number;
|
|
63
|
+
ban: number;
|
|
64
|
+
}
|
|
65
|
+
export interface WatchdogAssessment {
|
|
66
|
+
score: number;
|
|
67
|
+
labels: string[];
|
|
68
|
+
reasons: string[];
|
|
69
|
+
evidenceRefs: string[];
|
|
70
|
+
}
|
|
71
|
+
export interface GameSecurityGuardOptions {
|
|
72
|
+
rules?: AuthoritativeRule[];
|
|
73
|
+
tickRateHz?: number;
|
|
74
|
+
maxLagTicks?: number;
|
|
75
|
+
watchdogThresholds?: Partial<WatchdogThresholds>;
|
|
76
|
+
}
|
|
77
|
+
export interface GuardDecision {
|
|
78
|
+
actionId: string;
|
|
79
|
+
playerId: string;
|
|
80
|
+
actionType: string;
|
|
81
|
+
tick: number;
|
|
82
|
+
stateAccepted: boolean;
|
|
83
|
+
disposition: GuardDisposition;
|
|
84
|
+
violations: GuardViolation[];
|
|
85
|
+
watchdog: WatchdogAssessment;
|
|
86
|
+
evidenceHash: string;
|
|
87
|
+
replayKey: string;
|
|
88
|
+
}
|
|
89
|
+
export interface GameSecurityReceipt {
|
|
90
|
+
schema: 'holoscript.game-security.receipt.v0.1.0';
|
|
91
|
+
plugin: 'game-security';
|
|
92
|
+
pluginVersion: string;
|
|
93
|
+
runId: string;
|
|
94
|
+
actionId: string;
|
|
95
|
+
playerId: string;
|
|
96
|
+
actionType: string;
|
|
97
|
+
stateAccepted: boolean;
|
|
98
|
+
disposition: GuardDisposition;
|
|
99
|
+
evidenceHash: string;
|
|
100
|
+
replayKey: string;
|
|
101
|
+
acceptance: {
|
|
102
|
+
accepted: boolean;
|
|
103
|
+
violations: Array<{
|
|
104
|
+
criterion: string;
|
|
105
|
+
message: string;
|
|
106
|
+
}>;
|
|
107
|
+
};
|
|
108
|
+
watchdogScore: number;
|
|
109
|
+
payloadHash: string;
|
|
110
|
+
}
|
|
111
|
+
export declare class AuthoritativeActionGuard {
|
|
112
|
+
private readonly rules;
|
|
113
|
+
private readonly tickRateHz;
|
|
114
|
+
private readonly maxLagTicks;
|
|
115
|
+
private readonly watchdogThresholds;
|
|
116
|
+
private readonly seenActionIds;
|
|
117
|
+
private readonly decisions;
|
|
118
|
+
constructor(options?: GameSecurityGuardOptions);
|
|
119
|
+
evaluate(action: ProposedGameAction, state: AuthoritativeWorldState, watchdogSignals?: AiWatchdogSignal[]): GuardDecision;
|
|
120
|
+
getDecisions(): GuardDecision[];
|
|
121
|
+
clearEvidence(): void;
|
|
122
|
+
private evaluateProtocolRules;
|
|
123
|
+
}
|
|
124
|
+
export declare function createGameSecurityGuard(options?: GameSecurityGuardOptions): AuthoritativeActionGuard;
|
|
125
|
+
export declare function createMovementEnvelopeRule(options: {
|
|
126
|
+
maxMetersPerSecond: number;
|
|
127
|
+
maxAccelerationMetersPerSecondSquared?: number;
|
|
128
|
+
}): AuthoritativeRule;
|
|
129
|
+
export declare function createCooldownRule(cooldownTicksByAction: Record<string, number>): AuthoritativeRule;
|
|
130
|
+
export declare function createResourceSpendRule(resourcePayloadKey?: string): AuthoritativeRule;
|
|
131
|
+
export declare function combineWatchdogSignals(signals: AiWatchdogSignal[]): WatchdogAssessment;
|
|
132
|
+
export declare function buildGameSecurityReceipt(decision: GuardDecision, options?: {
|
|
133
|
+
runId?: string;
|
|
134
|
+
pluginVersion?: string;
|
|
135
|
+
}): GameSecurityReceipt;
|
|
136
|
+
export declare function stableHash(value: unknown): string;
|
|
137
|
+
export declare function stableStringify(value: unknown): string;
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AuthoritativeActionGuard = void 0;
|
|
4
|
+
exports.createGameSecurityGuard = createGameSecurityGuard;
|
|
5
|
+
exports.createMovementEnvelopeRule = createMovementEnvelopeRule;
|
|
6
|
+
exports.createCooldownRule = createCooldownRule;
|
|
7
|
+
exports.createResourceSpendRule = createResourceSpendRule;
|
|
8
|
+
exports.combineWatchdogSignals = combineWatchdogSignals;
|
|
9
|
+
exports.buildGameSecurityReceipt = buildGameSecurityReceipt;
|
|
10
|
+
exports.stableHash = stableHash;
|
|
11
|
+
exports.stableStringify = stableStringify;
|
|
12
|
+
const crypto_1 = require("crypto");
|
|
13
|
+
const DEFAULT_THRESHOLDS = {
|
|
14
|
+
review: 0.5,
|
|
15
|
+
quarantine: 0.75,
|
|
16
|
+
kick: 0.9,
|
|
17
|
+
ban: 0.98,
|
|
18
|
+
};
|
|
19
|
+
class AuthoritativeActionGuard {
|
|
20
|
+
rules;
|
|
21
|
+
tickRateHz;
|
|
22
|
+
maxLagTicks;
|
|
23
|
+
watchdogThresholds;
|
|
24
|
+
seenActionIds = new Set();
|
|
25
|
+
decisions = [];
|
|
26
|
+
constructor(options = {}) {
|
|
27
|
+
this.rules = options.rules ?? [];
|
|
28
|
+
this.tickRateHz = options.tickRateHz ?? 60;
|
|
29
|
+
this.maxLagTicks = options.maxLagTicks ?? 12;
|
|
30
|
+
this.watchdogThresholds = { ...DEFAULT_THRESHOLDS, ...options.watchdogThresholds };
|
|
31
|
+
}
|
|
32
|
+
evaluate(action, state, watchdogSignals = []) {
|
|
33
|
+
const context = {
|
|
34
|
+
nowTick: state.tick,
|
|
35
|
+
tickRateHz: this.tickRateHz,
|
|
36
|
+
maxLagTicks: this.maxLagTicks,
|
|
37
|
+
};
|
|
38
|
+
const violations = [
|
|
39
|
+
...this.evaluateProtocolRules(action, state),
|
|
40
|
+
...this.rules.flatMap((rule) => rule.evaluate(action, state, context)),
|
|
41
|
+
];
|
|
42
|
+
const watchdog = combineWatchdogSignals(watchdogSignals);
|
|
43
|
+
const disposition = chooseDisposition(violations, watchdog, this.watchdogThresholds);
|
|
44
|
+
const stateAccepted = disposition === 'accept' || disposition === 'review';
|
|
45
|
+
const evidenceBase = {
|
|
46
|
+
action,
|
|
47
|
+
stateTick: state.tick,
|
|
48
|
+
disposition,
|
|
49
|
+
stateAccepted,
|
|
50
|
+
violations,
|
|
51
|
+
watchdog,
|
|
52
|
+
};
|
|
53
|
+
const evidenceHash = stableHash(evidenceBase);
|
|
54
|
+
const decision = {
|
|
55
|
+
actionId: action.actionId,
|
|
56
|
+
playerId: action.playerId,
|
|
57
|
+
actionType: action.type,
|
|
58
|
+
tick: action.tick,
|
|
59
|
+
stateAccepted,
|
|
60
|
+
disposition,
|
|
61
|
+
violations,
|
|
62
|
+
watchdog,
|
|
63
|
+
evidenceHash,
|
|
64
|
+
replayKey: `game-sec:${action.playerId}:${action.tick}:${evidenceHash.slice(0, 16)}`,
|
|
65
|
+
};
|
|
66
|
+
this.seenActionIds.add(action.actionId);
|
|
67
|
+
this.decisions.push(decision);
|
|
68
|
+
return decision;
|
|
69
|
+
}
|
|
70
|
+
getDecisions() {
|
|
71
|
+
return [...this.decisions];
|
|
72
|
+
}
|
|
73
|
+
clearEvidence() {
|
|
74
|
+
this.decisions.length = 0;
|
|
75
|
+
this.seenActionIds.clear();
|
|
76
|
+
}
|
|
77
|
+
evaluateProtocolRules(action, state) {
|
|
78
|
+
const violations = [];
|
|
79
|
+
const acceptedIds = new Set(state.acceptedActionIds ?? []);
|
|
80
|
+
if (this.seenActionIds.has(action.actionId) || acceptedIds.has(action.actionId)) {
|
|
81
|
+
violations.push({
|
|
82
|
+
ruleId: 'protocol.unique_action_id',
|
|
83
|
+
severity: 'error',
|
|
84
|
+
message: `Duplicate actionId "${action.actionId}" cannot enter authoritative state`,
|
|
85
|
+
evidence: { actionId: action.actionId },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (action.tick < state.tick - this.maxLagTicks) {
|
|
89
|
+
violations.push({
|
|
90
|
+
ruleId: 'protocol.max_lag_ticks',
|
|
91
|
+
severity: 'error',
|
|
92
|
+
message: `Action tick ${action.tick} is too far behind authoritative tick ${state.tick}`,
|
|
93
|
+
evidence: { actionTick: action.tick, stateTick: state.tick, maxLagTicks: this.maxLagTicks },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (action.tick > state.tick + 1) {
|
|
97
|
+
violations.push({
|
|
98
|
+
ruleId: 'protocol.future_tick',
|
|
99
|
+
severity: 'error',
|
|
100
|
+
message: `Action tick ${action.tick} is ahead of authoritative tick ${state.tick}`,
|
|
101
|
+
evidence: { actionTick: action.tick, stateTick: state.tick },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (!state.players[action.playerId]) {
|
|
105
|
+
violations.push({
|
|
106
|
+
ruleId: 'protocol.known_player',
|
|
107
|
+
severity: 'error',
|
|
108
|
+
message: `Unknown player "${action.playerId}" cannot submit authoritative actions`,
|
|
109
|
+
evidence: { playerId: action.playerId },
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return violations;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
exports.AuthoritativeActionGuard = AuthoritativeActionGuard;
|
|
116
|
+
function createGameSecurityGuard(options = {}) {
|
|
117
|
+
return new AuthoritativeActionGuard(options);
|
|
118
|
+
}
|
|
119
|
+
function createMovementEnvelopeRule(options) {
|
|
120
|
+
return {
|
|
121
|
+
id: 'movement.envelope',
|
|
122
|
+
description: 'Rejects impossible movement against authoritative position history.',
|
|
123
|
+
evaluate(action, state, context) {
|
|
124
|
+
if (action.type !== 'move')
|
|
125
|
+
return [];
|
|
126
|
+
const previous = state.players[action.playerId];
|
|
127
|
+
const nextPosition = readVector3(action.payload.position);
|
|
128
|
+
if (!previous?.position || !nextPosition)
|
|
129
|
+
return [];
|
|
130
|
+
const elapsedTicks = Math.max(1, action.tick - previous.tick);
|
|
131
|
+
const elapsedSeconds = elapsedTicks / context.tickRateHz;
|
|
132
|
+
const distance = distance3(previous.position, nextPosition);
|
|
133
|
+
const speed = distance / elapsedSeconds;
|
|
134
|
+
const violations = [];
|
|
135
|
+
if (speed > options.maxMetersPerSecond) {
|
|
136
|
+
violations.push({
|
|
137
|
+
ruleId: 'movement.max_speed',
|
|
138
|
+
severity: 'error',
|
|
139
|
+
message: `Movement speed ${round(speed)}m/s exceeds ${options.maxMetersPerSecond}m/s`,
|
|
140
|
+
evidence: {
|
|
141
|
+
distanceMeters: round(distance),
|
|
142
|
+
elapsedSeconds: round(elapsedSeconds),
|
|
143
|
+
speedMetersPerSecond: round(speed),
|
|
144
|
+
maxMetersPerSecond: options.maxMetersPerSecond,
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (options.maxAccelerationMetersPerSecondSquared !== undefined &&
|
|
149
|
+
previous.velocity !== undefined) {
|
|
150
|
+
const nextVelocity = scaleVector(subtractVector(nextPosition, previous.position), 1 / elapsedSeconds);
|
|
151
|
+
const acceleration = distance3(previous.velocity, nextVelocity) / Math.max(elapsedSeconds, Number.EPSILON);
|
|
152
|
+
if (acceleration > options.maxAccelerationMetersPerSecondSquared) {
|
|
153
|
+
violations.push({
|
|
154
|
+
ruleId: 'movement.max_acceleration',
|
|
155
|
+
severity: 'error',
|
|
156
|
+
message: `Movement acceleration ${round(acceleration)}m/s^2 exceeds ${options.maxAccelerationMetersPerSecondSquared}m/s^2`,
|
|
157
|
+
evidence: {
|
|
158
|
+
accelerationMetersPerSecondSquared: round(acceleration),
|
|
159
|
+
maxAccelerationMetersPerSecondSquared: options.maxAccelerationMetersPerSecondSquared,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return violations;
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function createCooldownRule(cooldownTicksByAction) {
|
|
169
|
+
return {
|
|
170
|
+
id: 'action.cooldown',
|
|
171
|
+
description: 'Rejects actions attempted before their authoritative cooldown expires.',
|
|
172
|
+
evaluate(action, state) {
|
|
173
|
+
const previous = state.players[action.playerId];
|
|
174
|
+
const configuredCooldown = cooldownTicksByAction[action.type];
|
|
175
|
+
const nextAllowedTick = previous?.cooldowns?.[action.type];
|
|
176
|
+
if (configuredCooldown === undefined || nextAllowedTick === undefined)
|
|
177
|
+
return [];
|
|
178
|
+
if (action.tick >= nextAllowedTick)
|
|
179
|
+
return [];
|
|
180
|
+
return [
|
|
181
|
+
{
|
|
182
|
+
ruleId: 'action.cooldown',
|
|
183
|
+
severity: 'error',
|
|
184
|
+
message: `Action "${action.type}" is on cooldown until tick ${nextAllowedTick}`,
|
|
185
|
+
evidence: {
|
|
186
|
+
actionType: action.type,
|
|
187
|
+
actionTick: action.tick,
|
|
188
|
+
nextAllowedTick,
|
|
189
|
+
configuredCooldownTicks: configuredCooldown,
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
];
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function createResourceSpendRule(resourcePayloadKey = 'cost') {
|
|
197
|
+
return {
|
|
198
|
+
id: 'resource.spend_authority',
|
|
199
|
+
description: 'Rejects resource spends that exceed authoritative balances.',
|
|
200
|
+
evaluate(action, state) {
|
|
201
|
+
const previous = state.players[action.playerId];
|
|
202
|
+
const requestedSpend = readNumberRecord(action.payload[resourcePayloadKey]);
|
|
203
|
+
if (!previous?.resources || !requestedSpend)
|
|
204
|
+
return [];
|
|
205
|
+
const violations = [];
|
|
206
|
+
for (const [resource, amount] of Object.entries(requestedSpend)) {
|
|
207
|
+
const available = previous.resources[resource] ?? 0;
|
|
208
|
+
if (amount > available) {
|
|
209
|
+
violations.push({
|
|
210
|
+
ruleId: 'resource.insufficient_balance',
|
|
211
|
+
severity: 'error',
|
|
212
|
+
message: `Resource spend "${resource}" requested ${amount}, only ${available} authoritative`,
|
|
213
|
+
evidence: { resource, requested: amount, available },
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return violations;
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function combineWatchdogSignals(signals) {
|
|
222
|
+
const normalized = signals.map((signal) => ({
|
|
223
|
+
...signal,
|
|
224
|
+
score: clamp01(signal.score),
|
|
225
|
+
}));
|
|
226
|
+
const score = normalized.reduce((max, signal) => Math.max(max, signal.score), 0);
|
|
227
|
+
return {
|
|
228
|
+
score,
|
|
229
|
+
labels: [...new Set(normalized.map((signal) => signal.label))],
|
|
230
|
+
reasons: [...new Set(normalized.flatMap((signal) => signal.reasons))],
|
|
231
|
+
evidenceRefs: [
|
|
232
|
+
...new Set(normalized
|
|
233
|
+
.map((signal) => signal.evidenceRef)
|
|
234
|
+
.filter((ref) => typeof ref === 'string' && ref.length > 0)),
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function buildGameSecurityReceipt(decision, options = {}) {
|
|
239
|
+
const receiptWithoutHash = {
|
|
240
|
+
schema: 'holoscript.game-security.receipt.v0.1.0',
|
|
241
|
+
plugin: 'game-security',
|
|
242
|
+
pluginVersion: options.pluginVersion ?? '0.1.0',
|
|
243
|
+
runId: options.runId ?? decision.replayKey,
|
|
244
|
+
actionId: decision.actionId,
|
|
245
|
+
playerId: decision.playerId,
|
|
246
|
+
actionType: decision.actionType,
|
|
247
|
+
stateAccepted: decision.stateAccepted,
|
|
248
|
+
disposition: decision.disposition,
|
|
249
|
+
evidenceHash: decision.evidenceHash,
|
|
250
|
+
replayKey: decision.replayKey,
|
|
251
|
+
acceptance: {
|
|
252
|
+
accepted: decision.stateAccepted,
|
|
253
|
+
violations: decision.violations.map((violation) => ({
|
|
254
|
+
criterion: violation.ruleId,
|
|
255
|
+
message: violation.message,
|
|
256
|
+
})),
|
|
257
|
+
},
|
|
258
|
+
watchdogScore: decision.watchdog.score,
|
|
259
|
+
};
|
|
260
|
+
return {
|
|
261
|
+
...receiptWithoutHash,
|
|
262
|
+
payloadHash: stableHash(receiptWithoutHash),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function stableHash(value) {
|
|
266
|
+
return `sha256:${(0, crypto_1.createHash)('sha256').update(stableStringify(value)).digest('hex')}`;
|
|
267
|
+
}
|
|
268
|
+
function stableStringify(value) {
|
|
269
|
+
return JSON.stringify(canonicalize(value)) ?? 'undefined';
|
|
270
|
+
}
|
|
271
|
+
function chooseDisposition(violations, watchdog, thresholds) {
|
|
272
|
+
const hasHardViolation = violations.some((violation) => violation.severity === 'error');
|
|
273
|
+
if (hasHardViolation) {
|
|
274
|
+
if (watchdog.score >= thresholds.ban)
|
|
275
|
+
return 'ban';
|
|
276
|
+
if (watchdog.score >= thresholds.kick)
|
|
277
|
+
return 'kick';
|
|
278
|
+
return 'reject';
|
|
279
|
+
}
|
|
280
|
+
if (watchdog.score >= thresholds.ban)
|
|
281
|
+
return 'ban';
|
|
282
|
+
if (watchdog.score >= thresholds.kick)
|
|
283
|
+
return 'kick';
|
|
284
|
+
if (watchdog.score >= thresholds.quarantine)
|
|
285
|
+
return 'quarantine';
|
|
286
|
+
if (watchdog.score >= thresholds.review)
|
|
287
|
+
return 'review';
|
|
288
|
+
return 'accept';
|
|
289
|
+
}
|
|
290
|
+
function canonicalize(value) {
|
|
291
|
+
if (Array.isArray(value)) {
|
|
292
|
+
return value.map((entry) => canonicalize(entry));
|
|
293
|
+
}
|
|
294
|
+
if (value && typeof value === 'object') {
|
|
295
|
+
const record = value;
|
|
296
|
+
const sorted = {};
|
|
297
|
+
for (const key of Object.keys(record).sort()) {
|
|
298
|
+
const entry = record[key];
|
|
299
|
+
if (entry !== undefined) {
|
|
300
|
+
sorted[key] = canonicalize(entry);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return sorted;
|
|
304
|
+
}
|
|
305
|
+
return value;
|
|
306
|
+
}
|
|
307
|
+
function readVector3(value) {
|
|
308
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
309
|
+
return null;
|
|
310
|
+
const record = value;
|
|
311
|
+
if (typeof record.x !== 'number' ||
|
|
312
|
+
typeof record.y !== 'number' ||
|
|
313
|
+
typeof record.z !== 'number') {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
return { x: record.x, y: record.y, z: record.z };
|
|
317
|
+
}
|
|
318
|
+
function readNumberRecord(value) {
|
|
319
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
320
|
+
return null;
|
|
321
|
+
const result = {};
|
|
322
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
323
|
+
if (typeof entry !== 'number')
|
|
324
|
+
return null;
|
|
325
|
+
result[key] = entry;
|
|
326
|
+
}
|
|
327
|
+
return result;
|
|
328
|
+
}
|
|
329
|
+
function subtractVector(a, b) {
|
|
330
|
+
return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z };
|
|
331
|
+
}
|
|
332
|
+
function scaleVector(v, scale) {
|
|
333
|
+
return { x: v.x * scale, y: v.y * scale, z: v.z * scale };
|
|
334
|
+
}
|
|
335
|
+
function distance3(a, b) {
|
|
336
|
+
const dx = a.x - b.x;
|
|
337
|
+
const dy = a.y - b.y;
|
|
338
|
+
const dz = a.z - b.z;
|
|
339
|
+
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
340
|
+
}
|
|
341
|
+
function clamp01(value) {
|
|
342
|
+
if (!Number.isFinite(value))
|
|
343
|
+
return 0;
|
|
344
|
+
return Math.max(0, Math.min(1, value));
|
|
345
|
+
}
|
|
346
|
+
function round(value) {
|
|
347
|
+
return Math.round(value * 1000) / 1000;
|
|
348
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@holoscript/game-security-plugin",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "HoloScript game-security plugin for authoritative action validation, replay evidence, and AI-watchdog quarantine decisions.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"@holoscript/core": "8.0.6"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/node": "^24.10.1",
|
|
16
|
+
"rimraf": "^5.0.5",
|
|
17
|
+
"typescript": "^5.9.3",
|
|
18
|
+
"vitest": "^4.1.5"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18.0.0"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"dev": "tsc --watch",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"test:coverage": "vitest run --coverage"
|
|
28
|
+
}
|
|
29
|
+
}
|