#2887 - Resolve conflicts from merge
This commit is contained in:
File diff suppressed because it is too large
Load Diff
33
src/primaite/game/agent/actions/__init__.py
Normal file
33
src/primaite/game/agent/actions/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
|
||||
from primaite.game.agent.actions import (
|
||||
abstract,
|
||||
acl,
|
||||
application,
|
||||
file,
|
||||
folder,
|
||||
host_nic,
|
||||
manager,
|
||||
network,
|
||||
node,
|
||||
service,
|
||||
session,
|
||||
software,
|
||||
)
|
||||
from primaite.game.agent.actions.manager import ActionManager
|
||||
|
||||
__all__ = (
|
||||
"abstract",
|
||||
"acl",
|
||||
"application",
|
||||
"software",
|
||||
"file",
|
||||
"folder",
|
||||
"host_nic",
|
||||
"manager",
|
||||
"network",
|
||||
"node",
|
||||
"service",
|
||||
"session",
|
||||
"ActionManager",
|
||||
)
|
||||
36
src/primaite/game/agent/actions/abstract.py
Normal file
36
src/primaite/game/agent/actions/abstract.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from typing import Any, ClassVar, Dict, Optional, Type
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from primaite.interface.request import RequestFormat
|
||||
|
||||
|
||||
class AbstractAction(BaseModel, ABC):
|
||||
"""Base class for actions."""
|
||||
|
||||
config: "AbstractAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(BaseModel, ABC):
|
||||
"""Base configuration schema for Actions."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
type: str = ""
|
||||
|
||||
_registry: ClassVar[Dict[str, Type[AbstractAction]]] = {}
|
||||
|
||||
def __init_subclass__(cls, identifier: Optional[str] = None, **kwargs: Any) -> None:
|
||||
super().__init_subclass__(**kwargs)
|
||||
if identifier is None:
|
||||
return
|
||||
if identifier in cls._registry:
|
||||
raise ValueError(f"Cannot create new action under reserved name {identifier}")
|
||||
cls._registry[identifier] = cls
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
pass
|
||||
157
src/primaite/game/agent/actions/acl.py
Normal file
157
src/primaite/game/agent/actions/acl.py
Normal file
@@ -0,0 +1,157 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from typing import Literal, Union
|
||||
|
||||
from primaite.game.agent.actions.manager import AbstractAction
|
||||
from primaite.interface.request import RequestFormat
|
||||
from primaite.utils.validation.ip_protocol import IPProtocol
|
||||
from primaite.utils.validation.ipv4_address import IPV4Address
|
||||
from primaite.utils.validation.port import Port
|
||||
|
||||
__all__ = (
|
||||
"RouterACLAddRuleAction",
|
||||
"RouterACLRemoveRuleAction",
|
||||
"FirewallACLAddRuleAction",
|
||||
"FirewallACLRemoveRuleAction",
|
||||
)
|
||||
|
||||
|
||||
class ACLAddRuleAbstractAction(AbstractAction, ABC):
|
||||
"""Base abstract class for ACL add rule actions."""
|
||||
|
||||
config: ConfigSchema = "ACLAddRuleAbstractAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Configuration Schema base for ACL add rule abstract actions."""
|
||||
|
||||
src_ip: IPV4Address
|
||||
protocol_name: Union[IPProtocol, Literal["ALL"]]
|
||||
permission: Literal["PERMIT", "DENY"]
|
||||
position: int
|
||||
dst_ip: Union[IPV4Address, Literal["ALL"]]
|
||||
src_port: Union[Port, Literal["ALL"]]
|
||||
dst_port: Union[Port, Literal["ALL"]]
|
||||
src_wildcard: Union[IPV4Address, Literal["NONE"]]
|
||||
dst_wildcard: Union[IPV4Address, Literal["NONE"]]
|
||||
|
||||
|
||||
class ACLRemoveRuleAbstractAction(AbstractAction, identifier="acl_remove_rule_abstract_action"):
|
||||
"""Base abstract class for ACL remove rule actions."""
|
||||
|
||||
config: ConfigSchema = "ACLRemoveRuleAbstractAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Configuration Schema base for ACL remove rule abstract actions."""
|
||||
|
||||
position: int
|
||||
|
||||
|
||||
class RouterACLAddRuleAction(ACLAddRuleAbstractAction, identifier="router_acl_add_rule"):
|
||||
"""Action which adds a rule to a router's ACL."""
|
||||
|
||||
config: "RouterACLAddRuleAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(ACLAddRuleAbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for RouterACLAddRuleAction."""
|
||||
|
||||
target_router: str
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.target_router,
|
||||
"acl",
|
||||
"add_rule",
|
||||
config.permission,
|
||||
config.protocol_name,
|
||||
str(config.src_ip),
|
||||
str(config.src_wildcard),
|
||||
config.src_port,
|
||||
str(config.dst_ip),
|
||||
str(config.dst_wildcard),
|
||||
config.dst_port,
|
||||
config.position,
|
||||
]
|
||||
|
||||
|
||||
class RouterACLRemoveRuleAction(ACLRemoveRuleAbstractAction, identifier="router_acl_remove_rule"):
|
||||
"""Action which removes a rule from a router's ACL."""
|
||||
|
||||
config: "RouterACLRemoveRuleAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(ACLRemoveRuleAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for RouterACLRemoveRuleAction."""
|
||||
|
||||
target_router: str
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return ["network", "node", config.target_router, "acl", "remove_rule", config.position]
|
||||
|
||||
|
||||
class FirewallACLAddRuleAction(ACLAddRuleAbstractAction, identifier="firewall_acl_add_rule"):
|
||||
"""Action which adds a rule to a firewall port's ACL."""
|
||||
|
||||
config: "FirewallACLAddRuleAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(ACLAddRuleAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for FirewallACLAddRuleAction."""
|
||||
|
||||
target_firewall_nodename: str
|
||||
firewall_port_name: str
|
||||
firewall_port_direction: str
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.target_firewall_nodename,
|
||||
config.firewall_port_name,
|
||||
config.firewall_port_direction,
|
||||
"acl",
|
||||
"add_rule",
|
||||
config.permission,
|
||||
config.protocol_name,
|
||||
str(config.src_ip),
|
||||
str(config.src_wildcard),
|
||||
config.src_port,
|
||||
str(config.dst_ip),
|
||||
str(config.dst_wildcard),
|
||||
config.dst_port,
|
||||
config.position,
|
||||
]
|
||||
|
||||
|
||||
class FirewallACLRemoveRuleAction(ACLRemoveRuleAbstractAction, identifier="firewall_acl_remove_rule"):
|
||||
"""Action which removes a rule from a firewall port's ACL."""
|
||||
|
||||
config: "FirewallACLRemoveRuleAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(ACLRemoveRuleAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for FirewallACLRemoveRuleAction."""
|
||||
|
||||
target_firewall_nodename: str
|
||||
firewall_port_name: str
|
||||
firewall_port_direction: str
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.target_firewall_nodename,
|
||||
config.firewall_port_name,
|
||||
config.firewall_port_direction,
|
||||
"acl",
|
||||
"remove_rule",
|
||||
config.position,
|
||||
]
|
||||
137
src/primaite/game/agent/actions/application.py
Normal file
137
src/primaite/game/agent/actions/application.py
Normal file
@@ -0,0 +1,137 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
from abc import ABC
|
||||
from typing import ClassVar
|
||||
|
||||
from primaite.game.agent.actions.abstract import AbstractAction
|
||||
from primaite.interface.request import RequestFormat
|
||||
|
||||
__all__ = (
|
||||
"NodeApplicationExecuteAction",
|
||||
"NodeApplicationScanAction",
|
||||
"NodeApplicationCloseAction",
|
||||
"NodeApplicationFixAction",
|
||||
"NodeApplicationInstallAction",
|
||||
"NodeApplicationRemoveAction",
|
||||
)
|
||||
|
||||
|
||||
class NodeApplicationAbstractAction(AbstractAction, ABC):
|
||||
"""
|
||||
Base class for application actions.
|
||||
|
||||
Any action which applies to an application and uses node_name and application_name as its only two parameters can
|
||||
inherit from this base class.
|
||||
"""
|
||||
|
||||
config: "NodeApplicationAbstractAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Base Configuration schema for Node Application actions."""
|
||||
|
||||
node_name: str
|
||||
application_name: str
|
||||
verb: ClassVar[str]
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"application",
|
||||
config.application_name,
|
||||
config.verb,
|
||||
]
|
||||
|
||||
|
||||
class NodeApplicationExecuteAction(NodeApplicationAbstractAction, identifier="node_application_execute"):
|
||||
"""Action which executes an application."""
|
||||
|
||||
config: "NodeApplicationExecuteAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeApplicationAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeApplicationExecuteAction."""
|
||||
|
||||
verb: str = "execute"
|
||||
|
||||
|
||||
class NodeApplicationScanAction(NodeApplicationAbstractAction, identifier="node_application_scan"):
|
||||
"""Action which scans an application."""
|
||||
|
||||
config: "NodeApplicationScanAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeApplicationAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeApplicationScanAction."""
|
||||
|
||||
verb: str = "scan"
|
||||
|
||||
|
||||
class NodeApplicationCloseAction(NodeApplicationAbstractAction, identifier="node_application_close"):
|
||||
"""Action which closes an application."""
|
||||
|
||||
config: "NodeApplicationCloseAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeApplicationAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeApplicationCloseAction."""
|
||||
|
||||
verb: str = "close"
|
||||
|
||||
|
||||
class NodeApplicationFixAction(NodeApplicationAbstractAction, identifier="node_application_fix"):
|
||||
"""Action which fixes an application."""
|
||||
|
||||
config: "NodeApplicationFixAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeApplicationAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeApplicationFixAction."""
|
||||
|
||||
verb: str = "fix"
|
||||
|
||||
|
||||
class NodeApplicationInstallAction(NodeApplicationAbstractAction, identifier="node_application_install"):
|
||||
"""Action which installs an application."""
|
||||
|
||||
config: "NodeApplicationInstallAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeApplicationAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeApplicationInstallAction."""
|
||||
|
||||
verb: str = "install"
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"software_manager",
|
||||
"application",
|
||||
config.verb,
|
||||
config.application_name,
|
||||
]
|
||||
|
||||
|
||||
class NodeApplicationRemoveAction(NodeApplicationAbstractAction, identifier="node_application_remove"):
|
||||
"""Action which removes/uninstalls an application."""
|
||||
|
||||
config: "NodeApplicationRemoveAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeApplicationAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeApplicationRemoveAction."""
|
||||
|
||||
verb: str = "uninstall"
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"software_manager",
|
||||
"application",
|
||||
config.verb,
|
||||
config.application_name,
|
||||
]
|
||||
189
src/primaite/game/agent/actions/file.py
Normal file
189
src/primaite/game/agent/actions/file.py
Normal file
@@ -0,0 +1,189 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
from abc import ABC
|
||||
from typing import ClassVar
|
||||
|
||||
from primaite.game.agent.actions.manager import AbstractAction
|
||||
from primaite.interface.request import RequestFormat
|
||||
|
||||
__all__ = (
|
||||
"NodeFileCreateAction",
|
||||
"NodeFileScanAction",
|
||||
"NodeFileDeleteAction",
|
||||
"NodeFileRestoreAction",
|
||||
"NodeFileCorruptAction",
|
||||
"NodeFileAccessAction",
|
||||
"NodeFileCheckhashAction",
|
||||
"NodeFileRepairAction",
|
||||
)
|
||||
|
||||
|
||||
class NodeFileAbstractAction(AbstractAction, ABC):
|
||||
"""Abstract base class for file actions.
|
||||
|
||||
Any action which applies to a file and uses node_name, folder_name, and file_name as its
|
||||
only three parameters can inherit from this base class.
|
||||
"""
|
||||
|
||||
config: "NodeFileAbstractAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for NodeFileAbstractAction."""
|
||||
|
||||
node_name: str
|
||||
folder_name: str
|
||||
file_name: str
|
||||
verb: ClassVar[str]
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
if config.node_name is None or config.folder_name is None or config.file_name is None:
|
||||
return ["do_nothing"]
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"file_system",
|
||||
"folder",
|
||||
config.folder_name,
|
||||
"file",
|
||||
config.file_name,
|
||||
config.verb,
|
||||
]
|
||||
|
||||
|
||||
class NodeFileCreateAction(NodeFileAbstractAction, identifier="node_file_create"):
|
||||
"""Action which creates a new file in a given folder."""
|
||||
|
||||
config: "NodeFileCreateAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFileAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeFileCreateAction."""
|
||||
|
||||
verb: ClassVar[str] = "create"
|
||||
force: bool = False
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
if config.node_name is None or config.folder_name is None or config.file_name is None:
|
||||
return ["do_nothing"]
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"file_system",
|
||||
config.verb,
|
||||
"file",
|
||||
config.folder_name,
|
||||
config.file_name,
|
||||
config.verb,
|
||||
]
|
||||
|
||||
|
||||
class NodeFileScanAction(NodeFileAbstractAction, identifier="node_file_scan"):
|
||||
"""Action which scans a file."""
|
||||
|
||||
config: "NodeFileScanAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFileAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeFileScanAction."""
|
||||
|
||||
verb: ClassVar[str] = "scan"
|
||||
|
||||
|
||||
class NodeFileDeleteAction(NodeFileAbstractAction, identifier="node_file_delete"):
|
||||
"""Action which deletes a file."""
|
||||
|
||||
config: "NodeFileDeleteAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFileAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeFileDeleteAction."""
|
||||
|
||||
verb: ClassVar[str] = "delete"
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
if config.node_name is None or config.folder_name is None or config.file_name is None:
|
||||
return ["do_nothing"]
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"file_system",
|
||||
config.verb,
|
||||
"file",
|
||||
config.folder_name,
|
||||
config.file_name,
|
||||
]
|
||||
|
||||
|
||||
class NodeFileRestoreAction(NodeFileAbstractAction, identifier="node_file_restore"):
|
||||
"""Action which restores a file."""
|
||||
|
||||
config: "NodeFileRestoreAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFileAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeFileRestoreAction."""
|
||||
|
||||
verb: ClassVar[str] = "restore"
|
||||
|
||||
|
||||
class NodeFileCorruptAction(NodeFileAbstractAction, identifier="node_file_corrupt"):
|
||||
"""Action which corrupts a file."""
|
||||
|
||||
config: "NodeFileCorruptAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFileAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeFileCorruptAction."""
|
||||
|
||||
verb: ClassVar[str] = "corrupt"
|
||||
|
||||
|
||||
class NodeFileAccessAction(NodeFileAbstractAction, identifier="node_file_access"):
|
||||
"""Action which increases a file's access count."""
|
||||
|
||||
config: "NodeFileAccessAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFileAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeFileAccessAction."""
|
||||
|
||||
verb: ClassVar[str] = "access"
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
if config.node_name is None or config.folder_name is None or config.file_name is None:
|
||||
return ["do_nothing"]
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"file_system",
|
||||
config.verb,
|
||||
config.folder_name,
|
||||
config.file_name,
|
||||
]
|
||||
|
||||
|
||||
class NodeFileCheckhashAction(NodeFileAbstractAction, identifier="node_file_checkhash"):
|
||||
"""Action which checks the hash of a file."""
|
||||
|
||||
config: "NodeFileCheckhashAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFileAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeFileCheckhashAction."""
|
||||
|
||||
verb: ClassVar[str] = "checkhash"
|
||||
|
||||
|
||||
class NodeFileRepairAction(NodeFileAbstractAction, identifier="node_file_repair"):
|
||||
"""Action which repairs a file."""
|
||||
|
||||
config: "NodeFileRepairAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFileAbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for NodeFileRepairAction."""
|
||||
|
||||
verb: ClassVar[str] = "repair"
|
||||
117
src/primaite/game/agent/actions/folder.py
Normal file
117
src/primaite/game/agent/actions/folder.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
from abc import ABC
|
||||
from typing import ClassVar
|
||||
|
||||
from primaite.game.agent.actions.manager import AbstractAction
|
||||
from primaite.interface.request import RequestFormat
|
||||
|
||||
__all__ = (
|
||||
"NodeFolderScanAction",
|
||||
"NodeFolderCheckhashAction",
|
||||
"NodeFolderRepairAction",
|
||||
"NodeFolderRestoreAction",
|
||||
"NodeFolderCreateAction",
|
||||
)
|
||||
|
||||
|
||||
class NodeFolderAbstractAction(AbstractAction, ABC):
|
||||
"""
|
||||
Base class for folder actions.
|
||||
|
||||
Any action which applies to a folder and uses node_name and folder_name as its only two parameters can inherit from
|
||||
this base class.
|
||||
"""
|
||||
|
||||
config: "NodeFolderAbstractAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Base configuration schema for NodeFolder actions."""
|
||||
|
||||
node_name: str
|
||||
folder_name: str
|
||||
verb: ClassVar[str]
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
if config.node_name is None or config.folder_name is None:
|
||||
return ["do_nothing"]
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"file_system",
|
||||
"folder",
|
||||
config.folder_name,
|
||||
config.verb,
|
||||
]
|
||||
|
||||
|
||||
class NodeFolderScanAction(NodeFolderAbstractAction, identifier="node_folder_scan"):
|
||||
"""Action which scans a folder."""
|
||||
|
||||
config: "NodeFolderScanAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFolderAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeFolderScanAction."""
|
||||
|
||||
verb: ClassVar[str] = "scan"
|
||||
|
||||
|
||||
class NodeFolderCheckhashAction(NodeFolderAbstractAction, identifier="node_folder_checkhash"):
|
||||
"""Action which checks the hash of a folder."""
|
||||
|
||||
config: "NodeFolderCheckhashAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFolderAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeFolderCheckhashAction."""
|
||||
|
||||
verb: ClassVar[str] = "checkhash"
|
||||
|
||||
|
||||
class NodeFolderRepairAction(NodeFolderAbstractAction, identifier="node_folder_repair"):
|
||||
"""Action which repairs a folder."""
|
||||
|
||||
config: "NodeFolderRepairAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFolderAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeFolderRepairAction."""
|
||||
|
||||
verb: ClassVar[str] = "repair"
|
||||
|
||||
|
||||
class NodeFolderRestoreAction(NodeFolderAbstractAction, identifier="node_folder_restore"):
|
||||
"""Action which restores a folder."""
|
||||
|
||||
config: "NodeFolderRestoreAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFolderAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeFolderRestoreAction."""
|
||||
|
||||
verb: ClassVar[str] = "restore"
|
||||
|
||||
|
||||
class NodeFolderCreateAction(NodeFolderAbstractAction, identifier="node_folder_create"):
|
||||
"""Action which creates a new folder."""
|
||||
|
||||
config: "NodeFolderCreateAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeFolderAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeFolderCreateAction."""
|
||||
|
||||
verb: ClassVar[str] = "create"
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
if config.node_name is None or config.folder_name is None:
|
||||
return ["do_nothing"]
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"file_system",
|
||||
config.verb,
|
||||
"folder",
|
||||
config.folder_name,
|
||||
]
|
||||
62
src/primaite/game/agent/actions/host_nic.py
Normal file
62
src/primaite/game/agent/actions/host_nic.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
from abc import ABC
|
||||
from typing import ClassVar
|
||||
|
||||
from primaite.game.agent.actions.manager import AbstractAction
|
||||
from primaite.interface.request import RequestFormat
|
||||
|
||||
__all__ = ("HostNICEnableAction", "HostNICDisableAction")
|
||||
|
||||
|
||||
class HostNICAbstractAction(AbstractAction, ABC):
|
||||
"""
|
||||
Abstract base class for NIC actions.
|
||||
|
||||
Any action which applies to a NIC and uses node_name and nic_num as its only two parameters can inherit from this
|
||||
base class.
|
||||
"""
|
||||
|
||||
config: "HostNICAbstractAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Base Configuration schema for HostNIC actions."""
|
||||
|
||||
node_name: str
|
||||
nic_num: int
|
||||
verb: ClassVar[str]
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
if config.node_name is None or config.nic_num is None:
|
||||
return ["do_nothing"]
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"network_interface",
|
||||
config.nic_num,
|
||||
config.verb,
|
||||
]
|
||||
|
||||
|
||||
class HostNICEnableAction(HostNICAbstractAction, identifier="host_nic_enable"):
|
||||
"""Action which enables a NIC."""
|
||||
|
||||
config: "HostNICEnableAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(HostNICAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for HostNICEnableAction."""
|
||||
|
||||
verb: ClassVar[str] = "enable"
|
||||
|
||||
|
||||
class HostNICDisableAction(HostNICAbstractAction, identifier="host_nic_disable"):
|
||||
"""Action which disables a NIC."""
|
||||
|
||||
config: "HostNICDisableAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(HostNICAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for HostNICDisableAction."""
|
||||
|
||||
verb: ClassVar[str] = "disable"
|
||||
108
src/primaite/game/agent/actions/manager.py
Normal file
108
src/primaite/game/agent/actions/manager.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
"""yaml example.
|
||||
|
||||
agents:
|
||||
- name: agent_1
|
||||
action_space:
|
||||
actions:
|
||||
- do_nothing
|
||||
- node_service_start
|
||||
- node_service_stop
|
||||
action_map:
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from gymnasium import spaces
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from primaite.game.agent.actions.abstract import AbstractAction
|
||||
from primaite.interface.request import RequestFormat
|
||||
|
||||
__all__ = ("DoNothingAction", "ActionManager")
|
||||
|
||||
|
||||
class DoNothingAction(AbstractAction, identifier="do_nothing"):
|
||||
"""Do Nothing Action."""
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for do_nothingAction."""
|
||||
|
||||
type: str = "do_nothing"
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return ["do_nothing"]
|
||||
|
||||
|
||||
class _ActionMapItem(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
action: str
|
||||
options: Dict
|
||||
|
||||
|
||||
class ActionManager(BaseModel):
|
||||
"""Class which manages the action space for an agent."""
|
||||
|
||||
class ConfigSchema(BaseModel):
|
||||
"""Config Schema for ActionManager."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
action_map: Dict[int, _ActionMapItem] = {}
|
||||
"""Mapping between integer action choices and CAOS actions."""
|
||||
|
||||
@field_validator("action_map", mode="after")
|
||||
def consecutive_action_nums(cls, v: Dict) -> Dict:
|
||||
"""Make sure all numbers between 0 and N are represented as dict keys in action map."""
|
||||
assert all([i in v.keys() for i in range(len(v))])
|
||||
return v
|
||||
|
||||
config: ActionManager.ConfigSchema = Field(default_factory=lambda: ActionManager.ConfigSchema())
|
||||
|
||||
action_map: Dict[int, Tuple[str, Dict]] = {}
|
||||
"""Init as empty, populate after model validation."""
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.action_map = {n: (v.action, v.options) for n, v in self.config.action_map.items()}
|
||||
|
||||
def get_action(self, action: int) -> Tuple[str, Dict]:
|
||||
"""
|
||||
Produce action in CAOS format.
|
||||
|
||||
The agent chooses an action (as an integer), this is converted into an action in CAOS format
|
||||
The CAOS format is basically an action identifier, followed by parameters stored in a dictionary.
|
||||
"""
|
||||
act_identifier, act_options = self.action_map[action]
|
||||
return act_identifier, act_options
|
||||
|
||||
def form_request(self, action_identifier: str, action_options: Dict) -> RequestFormat:
|
||||
"""Take action in CAOS format and use the execution definition to change it into PrimAITE request format."""
|
||||
act_class = AbstractAction._registry[action_identifier]
|
||||
config = act_class.ConfigSchema(**action_options)
|
||||
return act_class.form_request(config=config)
|
||||
|
||||
@property
|
||||
def space(self) -> spaces.Space:
|
||||
"""Return the gymnasium action space for this agent."""
|
||||
return spaces.Discrete(len(self.action_map))
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, cfg: Dict) -> "ActionManager":
|
||||
"""
|
||||
Construct an ActionManager from a config dictionary.
|
||||
|
||||
The action space config supports must contain the following key:
|
||||
``action_map`` - List of actions available to the agent, formatted as a dictionary where the key is the
|
||||
action number between 0 - N, and the value is the CAOS-formatted action.
|
||||
|
||||
:param cfg: The action space config.
|
||||
:type cfg: Dict
|
||||
:return: The constructed ActionManager.
|
||||
:rtype: ActionManager
|
||||
"""
|
||||
return cls(**cfg.get("options", {}), act_map=cfg.get("action_map"))
|
||||
57
src/primaite/game/agent/actions/network.py
Normal file
57
src/primaite/game/agent/actions/network.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
from primaite.game.agent.actions.manager import AbstractAction
|
||||
from primaite.interface.request import RequestFormat
|
||||
|
||||
__all__ = ("NetworkPortEnableAction", "NetworkPortDisableAction")
|
||||
|
||||
|
||||
class NetworkPortAbstractAction(AbstractAction, identifier="network_port_abstract"):
|
||||
"""Base class for Network port actions."""
|
||||
|
||||
config: "NetworkPortAbstractAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Base configuration schema for NetworkPort actions."""
|
||||
|
||||
target_nodename: str
|
||||
port_num: int
|
||||
verb: ClassVar[str]
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
if config.target_nodename is None or config.port_num is None:
|
||||
return ["do_nothing"]
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.target_nodename,
|
||||
"network_interface",
|
||||
config.port_num,
|
||||
config.verb,
|
||||
]
|
||||
|
||||
|
||||
class NetworkPortEnableAction(NetworkPortAbstractAction, identifier="network_port_enable"):
|
||||
"""Action which enables are port on a router or a firewall."""
|
||||
|
||||
config: "NetworkPortEnableAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NetworkPortAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NetworkPortEnableAction."""
|
||||
|
||||
verb: ClassVar[str] = "enable"
|
||||
|
||||
|
||||
class NetworkPortDisableAction(NetworkPortAbstractAction, identifier="network_port_disable"):
|
||||
"""Action which disables are port on a router or a firewall."""
|
||||
|
||||
config: "NetworkPortDisableAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NetworkPortAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NetworkPortDisableAction."""
|
||||
|
||||
verb: ClassVar[str] = "disable"
|
||||
186
src/primaite/game/agent/actions/node.py
Normal file
186
src/primaite/game/agent/actions/node.py
Normal file
@@ -0,0 +1,186 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
from abc import abstractmethod
|
||||
from typing import ClassVar, List, Optional, Union
|
||||
|
||||
from primaite.game.agent.actions.manager import AbstractAction
|
||||
from primaite.interface.request import RequestFormat
|
||||
from primaite.utils.validation.ip_protocol import IPProtocol
|
||||
from primaite.utils.validation.port import Port
|
||||
|
||||
__all__ = (
|
||||
"NodeOSScanAction",
|
||||
"NodeShutdownAction",
|
||||
"NodeStartupAction",
|
||||
"NodeResetAction",
|
||||
"NodeNMAPPingScanAction",
|
||||
"NodeNMAPPortScanAction",
|
||||
"NodeNetworkServiceReconAction",
|
||||
)
|
||||
|
||||
|
||||
class NodeAbstractAction(AbstractAction, identifier="node_abstract"):
|
||||
"""
|
||||
Abstract base class for node actions.
|
||||
|
||||
Any action which applies to a node and uses node_name as its only parameter can inherit from this base class.
|
||||
"""
|
||||
|
||||
config: "NodeAbstractAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Base Configuration schema for Node actions."""
|
||||
|
||||
node_name: str
|
||||
verb: ClassVar[str]
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
print(config)
|
||||
return ["network", "node", config.node_name, config.verb]
|
||||
|
||||
|
||||
class NodeOSScanAction(NodeAbstractAction, identifier="node_os_scan"):
|
||||
"""Action which scans a node's OS."""
|
||||
|
||||
config: "NodeOSScanAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeOSScanAction."""
|
||||
|
||||
verb: ClassVar[str] = "scan"
|
||||
|
||||
|
||||
class NodeShutdownAction(NodeAbstractAction, identifier="node_shutdown"):
|
||||
"""Action which shuts down a node."""
|
||||
|
||||
config: "NodeShutdownAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeShutdownAction."""
|
||||
|
||||
verb: ClassVar[str] = "shutdown"
|
||||
|
||||
|
||||
class NodeStartupAction(NodeAbstractAction, identifier="node_startup"):
|
||||
"""Action which starts up a node."""
|
||||
|
||||
config: "NodeStartupAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeStartupAction."""
|
||||
|
||||
verb: ClassVar[str] = "startup"
|
||||
|
||||
|
||||
class NodeResetAction(NodeAbstractAction, identifier="node_reset"):
|
||||
"""Action which resets a node."""
|
||||
|
||||
config: "NodeResetAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeResetAction."""
|
||||
|
||||
verb: ClassVar[str] = "reset"
|
||||
|
||||
|
||||
class NodeNMAPAbstractAction(AbstractAction, identifier="node_nmap_abstract_action"):
|
||||
"""Base class for NodeNMAP actions."""
|
||||
|
||||
config: "NodeNMAPAbstractAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Base Configuration Schema for NodeNMAP actions."""
|
||||
|
||||
target_ip_address: Union[str, List[str]]
|
||||
show: bool = False
|
||||
source_node: str
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
# NMAP action requests don't share a common format for their requests
|
||||
# This is just a placeholder to ensure the method is defined.
|
||||
pass
|
||||
|
||||
|
||||
class NodeNMAPPingScanAction(NodeNMAPAbstractAction, identifier="node_nmap_ping_scan"):
|
||||
"""Action which performs an NMAP ping scan."""
|
||||
|
||||
config: "NodeNMAPPingScanAction.ConfigSchema"
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: "NodeNMAPPingScanAction.ConfigSchema") -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.source_node,
|
||||
"application",
|
||||
"NMAP",
|
||||
"ping_scan",
|
||||
{"target_ip_address": config.target_ip_address, "show": config.show},
|
||||
]
|
||||
|
||||
|
||||
class NodeNMAPPortScanAction(NodeNMAPAbstractAction, identifier="node_nmap_port_scan"):
|
||||
"""Action which performs an NMAP port scan."""
|
||||
|
||||
config: "NodeNMAPPortScanAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeNMAPAbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for NodeNMAPPortScanAction."""
|
||||
|
||||
source_node: str
|
||||
target_protocol: Optional[Union[IPProtocol, List[IPProtocol]]] = None
|
||||
target_port: Optional[Union[Port, List[Port]]] = None
|
||||
show: Optional[bool] = (False,)
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.source_node,
|
||||
"application",
|
||||
"NMAP",
|
||||
"port_scan",
|
||||
{
|
||||
"target_ip_address": config.target_ip_address,
|
||||
"target_port": config.target_port,
|
||||
"target_protocol": config.target_protocol,
|
||||
"show": config.show,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class NodeNetworkServiceReconAction(NodeNMAPAbstractAction, identifier="node_network_service_recon"):
|
||||
"""Action which performs an NMAP network service recon (ping scan followed by port scan)."""
|
||||
|
||||
config: "NodeNetworkServiceReconAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeNMAPAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeNetworkServiceReconAction."""
|
||||
|
||||
target_protocol: Optional[Union[IPProtocol, List[IPProtocol]]] = None
|
||||
target_port: Optional[Union[Port, List[Port]]] = None
|
||||
show: Optional[bool] = (False,)
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.source_node,
|
||||
"application",
|
||||
"NMAP",
|
||||
"network_service_recon",
|
||||
{
|
||||
"target_ip_address": config.target_ip_address,
|
||||
"target_port": config.target_port,
|
||||
"target_protocol": config.target_protocol,
|
||||
"show": config.show,
|
||||
},
|
||||
]
|
||||
135
src/primaite/game/agent/actions/service.py
Normal file
135
src/primaite/game/agent/actions/service.py
Normal file
@@ -0,0 +1,135 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
from typing import ClassVar
|
||||
|
||||
from primaite.game.agent.actions.manager import AbstractAction
|
||||
from primaite.interface.request import RequestFormat
|
||||
|
||||
__all__ = (
|
||||
"NodeServiceScanAction",
|
||||
"NodeServiceStopAction",
|
||||
"NodeServiceStartAction",
|
||||
"NodeServicePauseAction",
|
||||
"NodeServiceResumeAction",
|
||||
"NodeServiceRestartAction",
|
||||
"NodeServiceDisableAction",
|
||||
"NodeServiceEnableAction",
|
||||
"NodeServiceFixAction",
|
||||
)
|
||||
|
||||
|
||||
class NodeServiceAbstractAction(AbstractAction, identifier="node_service_abstract"):
|
||||
"""Abstract Action for Node Service related actions.
|
||||
|
||||
Any actions which use node_name and service_name can inherit from this class.
|
||||
"""
|
||||
|
||||
config: "NodeServiceAbstractAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
node_name: str
|
||||
service_name: str
|
||||
verb: ClassVar[str]
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return ["network", "node", config.node_name, "service", config.service_name, config.verb]
|
||||
|
||||
|
||||
class NodeServiceScanAction(NodeServiceAbstractAction, identifier="node_service_scan"):
|
||||
"""Action which scans a service."""
|
||||
|
||||
config: "NodeServiceScanAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeServiceAbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for NodeServiceScanAction."""
|
||||
|
||||
verb: ClassVar[str] = "scan"
|
||||
|
||||
|
||||
class NodeServiceStopAction(NodeServiceAbstractAction, identifier="node_service_stop"):
|
||||
"""Action which stops a service."""
|
||||
|
||||
config: "NodeServiceStopAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeServiceAbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for NodeServiceStopAction."""
|
||||
|
||||
verb: ClassVar[str] = "stop"
|
||||
|
||||
|
||||
class NodeServiceStartAction(NodeServiceAbstractAction, identifier="node_service_start"):
|
||||
"""Action which starts a service."""
|
||||
|
||||
config: "NodeServiceStartAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeServiceAbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for NodeServiceStartAction."""
|
||||
|
||||
verb: ClassVar[str] = "start"
|
||||
|
||||
|
||||
class NodeServicePauseAction(NodeServiceAbstractAction, identifier="node_service_pause"):
|
||||
"""Action which pauses a service."""
|
||||
|
||||
config: "NodeServicePauseAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeServiceAbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for NodeServicePauseAction."""
|
||||
|
||||
verb: ClassVar[str] = "pause"
|
||||
|
||||
|
||||
class NodeServiceResumeAction(NodeServiceAbstractAction, identifier="node_service_resume"):
|
||||
"""Action which resumes a service."""
|
||||
|
||||
config: "NodeServiceResumeAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeServiceAbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for NodeServiceResumeAction."""
|
||||
|
||||
verb: ClassVar[str] = "resume"
|
||||
|
||||
|
||||
class NodeServiceRestartAction(NodeServiceAbstractAction, identifier="node_service_restart"):
|
||||
"""Action which restarts a service."""
|
||||
|
||||
config: "NodeServiceRestartAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeServiceAbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for NodeServiceRestartAction."""
|
||||
|
||||
verb: ClassVar[str] = "restart"
|
||||
|
||||
|
||||
class NodeServiceDisableAction(NodeServiceAbstractAction, identifier="node_service_disable"):
|
||||
"""Action which disables a service."""
|
||||
|
||||
config: "NodeServiceDisableAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeServiceAbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for NodeServiceDisableAction."""
|
||||
|
||||
verb: ClassVar[str] = "disable"
|
||||
|
||||
|
||||
class NodeServiceEnableAction(NodeServiceAbstractAction, identifier="node_service_enable"):
|
||||
"""Action which enables a service."""
|
||||
|
||||
config: "NodeServiceEnableAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeServiceAbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for NodeServiceEnableAction."""
|
||||
|
||||
verb: ClassVar[str] = "enable"
|
||||
|
||||
|
||||
class NodeServiceFixAction(NodeServiceAbstractAction, identifier="node_service_fix"):
|
||||
"""Action which fixes a service."""
|
||||
|
||||
config: "NodeServiceFixAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeServiceAbstractAction.ConfigSchema):
|
||||
"""Configuration Schema for NodeServiceFixAction."""
|
||||
|
||||
verb: ClassVar[str] = "fix"
|
||||
108
src/primaite/game/agent/actions/session.py
Normal file
108
src/primaite/game/agent/actions/session.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
from abc import abstractmethod
|
||||
|
||||
from primaite.game.agent.actions.manager import AbstractAction
|
||||
from primaite.interface.request import RequestFormat
|
||||
|
||||
__all__ = (
|
||||
"NodeSessionsRemoteLoginAction",
|
||||
"NodeSessionsRemoteLogoutAction",
|
||||
"NodeAccountChangePasswordAction",
|
||||
)
|
||||
|
||||
|
||||
class NodeSessionAbstractAction(AbstractAction, identifier="node_session_abstract"):
|
||||
"""Base class for NodeSession actions."""
|
||||
|
||||
config: "NodeSessionAbstractAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Base configuration schema for NodeSessionAbstractActions."""
|
||||
|
||||
node_name: str
|
||||
remote_ip: str
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""
|
||||
Abstract method for request forming.
|
||||
|
||||
Should return the action formatted as a request which can be ingested by the PrimAITE simulation.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class NodeSessionsRemoteLoginAction(NodeSessionAbstractAction, identifier="node_session_remote_login"):
|
||||
"""Action which performs a remote session login."""
|
||||
|
||||
config: "NodeSessionsRemoteLoginAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeSessionAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeSessionsRemoteLoginAction."""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
if config.node_name is None or config.remote_ip is None:
|
||||
return ["do_nothing"]
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"service",
|
||||
"Terminal",
|
||||
"node_session_remote_login",
|
||||
config.username,
|
||||
config.password,
|
||||
config.remote_ip,
|
||||
]
|
||||
|
||||
|
||||
class NodeSessionsRemoteLogoutAction(NodeSessionAbstractAction, identifier="node_session_remote_logoff"):
|
||||
"""Action which performs a remote session logout."""
|
||||
|
||||
config: "NodeSessionsRemoteLogoutAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeSessionAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeSessionsRemoteLogoutAction."""
|
||||
|
||||
verb: str = "remote_logoff"
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
if config.node_name is None or config.remote_ip is None:
|
||||
return ["do_nothing"]
|
||||
return ["network", "node", config.node_name, "service", "Terminal", config.verb, config.remote_ip]
|
||||
|
||||
|
||||
class NodeAccountChangePasswordAction(NodeSessionAbstractAction, identifier="node_account_change_password"):
|
||||
"""Action which changes the password for a user."""
|
||||
|
||||
config: "NodeAccountChangePasswordAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(NodeSessionAbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeAccountsChangePasswordAction."""
|
||||
|
||||
username: str
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"service",
|
||||
"UserManager",
|
||||
"change_password",
|
||||
config.username,
|
||||
config.current_password,
|
||||
config.new_password,
|
||||
]
|
||||
241
src/primaite/game/agent/actions/software.py
Normal file
241
src/primaite/game/agent/actions/software.py
Normal file
@@ -0,0 +1,241 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from primaite.game.agent.actions.manager import AbstractAction
|
||||
from primaite.interface.request import RequestFormat
|
||||
|
||||
__all__ = (
|
||||
"ConfigureRansomwareScriptAction",
|
||||
"ConfigureDoSBotAction",
|
||||
"ConfigureC2BeaconAction",
|
||||
"NodeSendRemoteCommandAction",
|
||||
"TerminalC2ServerAction",
|
||||
"RansomwareLaunchC2ServerAction",
|
||||
"ExfiltrationC2ServerAction",
|
||||
"ConfigureDatabaseClientAction",
|
||||
)
|
||||
|
||||
|
||||
class ConfigureRansomwareScriptAction(AbstractAction, identifier="configure_ransomware_script"):
|
||||
"""Action which sets config parameters for a ransomware script on a node."""
|
||||
|
||||
config: "ConfigureRansomwareScriptAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Configuration schema for ConfigureRansomwareScriptAction."""
|
||||
|
||||
node_name: str
|
||||
server_ip_address: Optional[str] = None
|
||||
server_password: Optional[str] = None
|
||||
payload: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request that can be ingested by the simulation."""
|
||||
if config.node_name is None:
|
||||
return ["do_nothing"]
|
||||
data = dict(
|
||||
server_ip_address=config.server_ip_address,
|
||||
server_password=config.server_password,
|
||||
payload=config.payload,
|
||||
)
|
||||
return ["network", "node", config.node_name, "application", "RansomwareScript", "configure", data]
|
||||
|
||||
|
||||
class RansomwareConfigureC2ServerAction(ConfigureRansomwareScriptAction, identifier="c2_server_ransomware_configure"):
|
||||
"""Action which causes a C2 server to send a command to set options on a ransomware script remotely."""
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigureRansomwareScriptAction.ConfigSchema) -> RequestFormat:
|
||||
data = dict(
|
||||
server_ip_address=config.server_ip_address, server_password=config.server_password, payload=config.payload
|
||||
)
|
||||
return ["network", "node", config.node_name, "application", "C2Server", "ransomware_configure", data]
|
||||
|
||||
|
||||
class ConfigureDoSBotAction(AbstractAction, identifier="configure_dos_bot"):
|
||||
"""Action which sets config parameters for a DoS bot on a node."""
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Schema for options that can be passed to this action."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
node_name: str
|
||||
target_ip_address: Optional[str] = None
|
||||
target_port: Optional[str] = None
|
||||
payload: Optional[str] = None
|
||||
repeat: Optional[bool] = None
|
||||
port_scan_p_of_success: Optional[float] = None
|
||||
dos_intensity: Optional[float] = None
|
||||
max_sessions: Optional[int] = None
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request that can be ingested by the simulation."""
|
||||
data = dict(
|
||||
target_ip_address=config.target_ip_address,
|
||||
target_port=config.target_port,
|
||||
payload=config.payload,
|
||||
repeat=config.repeat,
|
||||
port_scan_p_of_success=config.port_scan_p_of_success,
|
||||
dos_intensity=config.dos_intensity,
|
||||
max_sessions=config.max_sessions,
|
||||
)
|
||||
data = {k: v for k, v in data.items() if v is not None}
|
||||
return ["network", "node", config.node_name, "application", "DoSBot", "configure", data]
|
||||
|
||||
|
||||
class ConfigureC2BeaconAction(AbstractAction, identifier="configure_c2_beacon"):
|
||||
"""Action which configures a C2 Beacon based on the parameters given."""
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Configuration schema for ConfigureC2BeaconAction."""
|
||||
|
||||
node_name: str
|
||||
c2_server_ip_address: str
|
||||
keep_alive_frequency: int = Field(default=5, ge=1)
|
||||
masquerade_protocol: str = Field(default="TCP")
|
||||
masquerade_port: str = Field(default="HTTP")
|
||||
|
||||
@classmethod
|
||||
def form_request(self, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request that can be ingested by the simulation."""
|
||||
data = dict(
|
||||
c2_server_ip_address=config.c2_server_ip_address,
|
||||
keep_alive_frequency=config.keep_alive_frequency,
|
||||
masquerade_protocol=config.masquerade_protocol,
|
||||
masquerade_port=config.masquerade_port,
|
||||
)
|
||||
return ["network", "node", config.node_name, "application", "C2Beacon", "configure", data]
|
||||
|
||||
|
||||
class NodeSendRemoteCommandAction(AbstractAction, identifier="node_send_remote_command"):
|
||||
"""Action which sends a terminal command to a remote node via SSH."""
|
||||
|
||||
config: "NodeSendRemoteCommandAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Configuration schema for NodeSendRemoteCommandAction."""
|
||||
|
||||
node_name: str
|
||||
remote_ip: str
|
||||
command: RequestFormat
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request which can be ingested by the PrimAITE simulation."""
|
||||
return [
|
||||
"network",
|
||||
"node",
|
||||
config.node_name,
|
||||
"service",
|
||||
"Terminal",
|
||||
"send_remote_command",
|
||||
config.remote_ip,
|
||||
{"command": config.command},
|
||||
]
|
||||
|
||||
|
||||
class TerminalC2ServerAction(AbstractAction, identifier="c2_server_terminal_command"):
|
||||
"""Action which causes the C2 Server to send a command to the C2 Beacon to execute the terminal command passed."""
|
||||
|
||||
config: "TerminalC2ServerAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Schema for options that can be passed to this action."""
|
||||
|
||||
node_name: str
|
||||
commands: Union[List[RequestFormat], RequestFormat]
|
||||
ip_address: Optional[str]
|
||||
username: Optional[str]
|
||||
password: Optional[str]
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request that can be ingested by the simulation."""
|
||||
if config.node_name is None:
|
||||
return ["do_nothing"]
|
||||
|
||||
command_model = {
|
||||
"commands": config.commands,
|
||||
"ip_address": config.ip_address,
|
||||
"username": config.username,
|
||||
"password": config.password,
|
||||
}
|
||||
return ["network", "node", config.node_name, "application", "C2Server", "terminal_command", command_model]
|
||||
|
||||
|
||||
class RansomwareLaunchC2ServerAction(AbstractAction, identifier="c2_server_ransomware_launch"):
|
||||
"""Action which causes the C2 Server to send a command to the C2 Beacon to launch the RansomwareScript."""
|
||||
|
||||
config: "RansomwareLaunchC2ServerAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Configuration schema for RansomwareLaunchC2ServerAction."""
|
||||
|
||||
node_name: str
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request that can be ingested by the simulation."""
|
||||
if config.node_name is None:
|
||||
return ["do_nothing"]
|
||||
# This action currently doesn't require any further configuration options.
|
||||
return ["network", "node", config.node_name, "application", "C2Server", "ransomware_launch"]
|
||||
|
||||
|
||||
class ExfiltrationC2ServerAction(AbstractAction, identifier="c2_server_data_exfiltrate"):
|
||||
"""Action which exfiltrates a target file from a certain node onto the C2 beacon and then the C2 Server."""
|
||||
|
||||
config: "ExfiltrationC2ServerAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Schema for options that can be passed to this action."""
|
||||
|
||||
node_name: str
|
||||
username: Optional[str]
|
||||
password: Optional[str]
|
||||
target_ip_address: str
|
||||
target_file_name: str
|
||||
target_folder_name: str
|
||||
exfiltration_folder_name: Optional[str]
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request that can be ingested by the simulation."""
|
||||
if config.node_name is None:
|
||||
return ["do_nothing"]
|
||||
|
||||
command_model = {
|
||||
"target_file_name": config.target_file_name,
|
||||
"target_folder_name": config.target_folder_name,
|
||||
"exfiltration_folder_name": config.exfiltration_folder_name,
|
||||
"target_ip_address": config.target_ip_address,
|
||||
"username": config.username,
|
||||
"password": config.password,
|
||||
}
|
||||
return ["network", "node", config.node_name, "application", "C2Server", "exfiltrate", command_model]
|
||||
|
||||
|
||||
class ConfigureDatabaseClientAction(AbstractAction, identifier="configure_database_client"):
|
||||
"""Action which sets config parameters for a database client on a node."""
|
||||
|
||||
config: "ConfigureDatabaseClientAction.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractAction.ConfigSchema):
|
||||
"""Schema for options that can be passed to this action."""
|
||||
|
||||
node_name: str
|
||||
server_ip_address: Optional[str] = None
|
||||
server_password: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def form_request(cls, config: ConfigSchema) -> RequestFormat:
|
||||
"""Return the action formatted as a request that can be ingested by the simulation."""
|
||||
if config.node_name is None:
|
||||
return ["do_nothing"]
|
||||
data = {"server_ip_address": config.server_ip_address, "server_password": config.server_password}
|
||||
return ["network", "node", config.node_name, "application", "DatabaseClient", "configure", data]
|
||||
@@ -1,6 +1,7 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from prettytable import MARKDOWN, PrettyTable
|
||||
|
||||
@@ -20,20 +21,22 @@ class _NotJSONFilter(logging.Filter):
|
||||
|
||||
class AgentLog:
|
||||
"""
|
||||
A Agent Log class is a simple logger dedicated to managing and writing logging updates and information for an agent.
|
||||
An Agent Log class is a simple logger dedicated to managing and writing updates and information for an agent.
|
||||
|
||||
Each log message is written to a file located at: <simulation output directory>/agent_name/agent_name.log
|
||||
Each log message is written to a file located at:
|
||||
<simulation output directory>/agent_name/agent_name.log
|
||||
"""
|
||||
|
||||
def __init__(self, agent_name: str):
|
||||
def __init__(self, agent_name: Optional[str]):
|
||||
"""
|
||||
Constructs a Agent Log instance for a given hostname.
|
||||
|
||||
:param hostname: The hostname associated with the system logs being recorded.
|
||||
:param agent_name: The agent_name associated with the system logs being recorded.
|
||||
"""
|
||||
self.agent_name = agent_name
|
||||
self.current_episode: int = 1
|
||||
super().__init__()
|
||||
self.agent_name = agent_name if agent_name else "unnamed_agent"
|
||||
self.current_timestep: int = 0
|
||||
self.current_episode: int = 1
|
||||
self.setup_logger()
|
||||
|
||||
@property
|
||||
@@ -90,7 +93,7 @@ class AgentLog:
|
||||
|
||||
def _write_to_terminal(self, msg: str, level: str, to_terminal: bool = False):
|
||||
if to_terminal or SIM_OUTPUT.write_agent_log_to_terminal:
|
||||
print(f"{self.agent_name}: ({ self.timestep}) ({level}) {msg}")
|
||||
print(f"{self.agent_name}: ({self.timestep}) ({level}) {msg}")
|
||||
|
||||
def debug(self, msg: str, to_terminal: bool = False):
|
||||
"""
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
"""Interface for agents."""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
from typing import Any, ClassVar, Dict, List, Literal, Optional, Tuple, Type, TYPE_CHECKING
|
||||
|
||||
from gymnasium.core import ActType, ObsType
|
||||
from pydantic import BaseModel, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from primaite.game.agent.actions import ActionManager
|
||||
from primaite.game.agent.agent_log import AgentLog
|
||||
@@ -15,6 +17,8 @@ from primaite.interface.request import RequestFormat, RequestResponse
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
__all__ = ("AgentHistoryItem", "AbstractAgent", "AbstractScriptedAgent", "ProxyAgent")
|
||||
|
||||
|
||||
class AgentHistoryItem(BaseModel):
|
||||
"""One entry of an agent's action log - what the agent did and how the simulator responded in 1 step."""
|
||||
@@ -39,89 +43,56 @@ class AgentHistoryItem(BaseModel):
|
||||
reward_info: Dict[str, Any] = {}
|
||||
|
||||
|
||||
class AgentStartSettings(BaseModel):
|
||||
"""Configuration values for when an agent starts performing actions."""
|
||||
|
||||
start_step: int = 5
|
||||
"The timestep at which an agent begins performing it's actions"
|
||||
frequency: int = 5
|
||||
"The number of timesteps to wait between performing actions"
|
||||
variance: int = 0
|
||||
"The amount the frequency can randomly change to"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_variance_lt_frequency(self) -> "AgentStartSettings":
|
||||
"""
|
||||
Make sure variance is equal to or lower than frequency.
|
||||
|
||||
This is because the calculation for the next execution time is now + (frequency +- variance). If variance were
|
||||
greater than frequency, sometimes the bracketed term would be negative and the attack would never happen again.
|
||||
"""
|
||||
if self.variance > self.frequency:
|
||||
raise ValueError(
|
||||
f"Agent start settings error: variance must be lower than frequency "
|
||||
f"{self.variance=}, {self.frequency=}"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class AgentSettings(BaseModel):
|
||||
"""Settings for configuring the operation of an agent."""
|
||||
|
||||
start_settings: Optional[AgentStartSettings] = None
|
||||
"Configuration for when an agent begins performing it's actions"
|
||||
flatten_obs: bool = True
|
||||
"Whether to flatten the observation space before passing it to the agent. True by default."
|
||||
action_masking: bool = False
|
||||
"Whether to return action masks at each step."
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: Optional[Dict]) -> "AgentSettings":
|
||||
"""Construct agent settings from a config dictionary.
|
||||
|
||||
:param config: A dict of options for the agent settings.
|
||||
:type config: Dict
|
||||
:return: The agent settings.
|
||||
:rtype: AgentSettings
|
||||
"""
|
||||
if config is None:
|
||||
return cls()
|
||||
|
||||
return cls(**config)
|
||||
|
||||
|
||||
class AbstractAgent(ABC):
|
||||
class AbstractAgent(BaseModel, ABC):
|
||||
"""Base class for scripted and RL agents."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_name: Optional[str],
|
||||
action_space: Optional[ActionManager],
|
||||
observation_space: Optional[ObservationManager],
|
||||
reward_function: Optional[RewardFunction],
|
||||
agent_settings: Optional[AgentSettings] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Initialize an agent.
|
||||
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
:param agent_name: Unique string identifier for the agent, for reporting and multi-agent purposes.
|
||||
:type agent_name: Optional[str]
|
||||
:param action_space: Action space for the agent.
|
||||
:type action_space: Optional[ActionManager]
|
||||
:param observation_space: Observation space for the agent.
|
||||
:type observation_space: Optional[ObservationSpace]
|
||||
:param reward_function: Reward function for the agent.
|
||||
:type reward_function: Optional[RewardFunction]
|
||||
:param agent_settings: Configurable Options for Abstracted Agents
|
||||
:type agent_settings: Optional[AgentSettings]
|
||||
"""
|
||||
self.agent_name: str = agent_name or "unnamed_agent"
|
||||
self.action_manager: Optional[ActionManager] = action_space
|
||||
self.observation_manager: Optional[ObservationManager] = observation_space
|
||||
self.reward_function: Optional[RewardFunction] = reward_function
|
||||
self.agent_settings = agent_settings or AgentSettings()
|
||||
self.history: List[AgentHistoryItem] = []
|
||||
self.logger = AgentLog(agent_name)
|
||||
class AgentSettingsSchema(BaseModel, ABC):
|
||||
"""Schema for the 'agent_settings' key."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
class ConfigSchema(BaseModel, ABC):
|
||||
"""Configuration Schema for AbstractAgents."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
type: str
|
||||
ref: str = ""
|
||||
"""name of the agent."""
|
||||
team: Optional[Literal["BLUE", "GREEN", "RED"]] = None
|
||||
agent_settings: AbstractAgent.AgentSettingsSchema = Field(default=lambda: AbstractAgent.AgentSettingsSchema())
|
||||
action_space: ActionManager.ConfigSchema = Field(default_factory=lambda: ActionManager.ConfigSchema())
|
||||
observation_space: ObservationManager.ConfigSchema = Field(
|
||||
default_factory=lambda: ObservationManager.ConfigSchema()
|
||||
)
|
||||
reward_function: RewardFunction.ConfigSchema = Field(default_factory=lambda: RewardFunction.ConfigSchema())
|
||||
|
||||
config: "AbstractAgent.ConfigSchema" = Field(default_factory=lambda: AbstractAgent.ConfigSchema())
|
||||
|
||||
logger: AgentLog = AgentLog(agent_name="Abstract_Agent")
|
||||
history: List[AgentHistoryItem] = []
|
||||
|
||||
action_manager: ActionManager = Field(default_factory=lambda: ActionManager())
|
||||
observation_manager: ObservationManager = Field(default_factory=lambda: ObservationManager())
|
||||
reward_function: RewardFunction = Field(default_factory=lambda: RewardFunction())
|
||||
|
||||
_registry: ClassVar[Dict[str, Type[AbstractAgent]]] = {}
|
||||
|
||||
def __init_subclass__(cls, identifier: Optional[str] = None, **kwargs: Any) -> None:
|
||||
super().__init_subclass__(**kwargs)
|
||||
if identifier is None:
|
||||
return
|
||||
if identifier in cls._registry:
|
||||
raise ValueError(f"Cannot create a new agent under reserved name {identifier}")
|
||||
cls._registry[identifier] = cls
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Overwrite the default empty action, observation, and rewards with ones defined through the config."""
|
||||
self.action_manager = ActionManager(config=self.config.action_space)
|
||||
self.observation_manager = ObservationManager(config=self.config.observation_space)
|
||||
self.reward_function = RewardFunction(config=self.config.reward_function)
|
||||
return super().model_post_init(__context)
|
||||
|
||||
def update_observation(self, state: Dict) -> ObsType:
|
||||
"""
|
||||
@@ -159,9 +130,9 @@ class AbstractAgent(ABC):
|
||||
"""
|
||||
# in RL agent, this method will send CAOS observation to RL agent, then receive a int 0-39,
|
||||
# then use a bespoke conversion to take 1-40 int back into CAOS action
|
||||
return ("DO_NOTHING", {})
|
||||
return ("do_nothing", {})
|
||||
|
||||
def format_request(self, action: Tuple[str, Dict], options: Dict[str, int]) -> List[str]:
|
||||
def format_request(self, action: Tuple[str, Dict], options: Dict[str, int]) -> RequestFormat:
|
||||
# this will take something like APPLICATION.EXECUTE and add things like target_ip_address in simulator.
|
||||
# therefore the execution definition needs to be a mapping from CAOS into SIMULATOR
|
||||
"""Format action into format expected by the simulator, and apply execution definition if applicable."""
|
||||
@@ -182,36 +153,47 @@ class AbstractAgent(ABC):
|
||||
"""Update the most recent history item with the reward value."""
|
||||
self.history[-1].reward = self.reward_function.current_reward
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: Dict) -> AbstractAgent:
|
||||
"""Grab the relevant agent class and construct an instance from a config dict."""
|
||||
agent_type = config["type"]
|
||||
agent_class = cls._registry[agent_type]
|
||||
return agent_class(config=config)
|
||||
|
||||
class AbstractScriptedAgent(AbstractAgent):
|
||||
|
||||
class AbstractScriptedAgent(AbstractAgent, identifier="AbstractScriptedAgent"):
|
||||
"""Base class for actors which generate their own behaviour."""
|
||||
|
||||
config: "AbstractScriptedAgent.ConfigSchema" = Field(default_factory=lambda: AbstractScriptedAgent.ConfigSchema())
|
||||
|
||||
class ConfigSchema(AbstractAgent.ConfigSchema):
|
||||
"""Configuration Schema for AbstractScriptedAgents."""
|
||||
|
||||
type: str = "AbstractScriptedAgent"
|
||||
|
||||
@abstractmethod
|
||||
def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]:
|
||||
"""Return an action to be taken in the environment."""
|
||||
return super().get_action(obs=obs, timestep=timestep)
|
||||
|
||||
|
||||
class ProxyAgent(AbstractAgent):
|
||||
class ProxyAgent(AbstractAgent, identifier="ProxyAgent"):
|
||||
"""Agent that sends observations to an RL model and receives actions from that model."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_name: Optional[str],
|
||||
action_space: Optional[ActionManager],
|
||||
observation_space: Optional[ObservationManager],
|
||||
reward_function: Optional[RewardFunction],
|
||||
agent_settings: Optional[AgentSettings] = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
agent_name=agent_name,
|
||||
action_space=action_space,
|
||||
observation_space=observation_space,
|
||||
reward_function=reward_function,
|
||||
)
|
||||
self.most_recent_action: ActType
|
||||
self.flatten_obs: bool = agent_settings.flatten_obs if agent_settings else False
|
||||
self.action_masking: bool = agent_settings.action_masking if agent_settings else False
|
||||
config: "ProxyAgent.ConfigSchema" = Field(default_factory=lambda: ProxyAgent.ConfigSchema())
|
||||
most_recent_action: ActType = None
|
||||
|
||||
class AgentSettingsSchema(AbstractAgent.AgentSettingsSchema):
|
||||
"""Schema for the `agent_settings` part of the agent config."""
|
||||
|
||||
flatten_obs: bool = False
|
||||
action_masking: bool = False
|
||||
|
||||
class ConfigSchema(AbstractAgent.ConfigSchema):
|
||||
"""Configuration Schema for Proxy Agent."""
|
||||
|
||||
type: str = "Proxy_Agent"
|
||||
agent_settings: ProxyAgent.AgentSettingsSchema = Field(default_factory=lambda: ProxyAgent.AgentSettingsSchema())
|
||||
|
||||
def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]:
|
||||
"""
|
||||
@@ -233,3 +215,8 @@ class ProxyAgent(AbstractAgent):
|
||||
The environment is responsible for calling this method when it receives an action from the agent policy.
|
||||
"""
|
||||
self.most_recent_action = action
|
||||
|
||||
@property
|
||||
def flatten_obs(self) -> bool:
|
||||
"""Return agent flatten_obs param."""
|
||||
return self.config.agent_settings.flatten_obs
|
||||
|
||||
@@ -17,5 +17,5 @@ from primaite.game.agent.observations.software_observation import ApplicationObs
|
||||
__all__ = [
|
||||
"ACLObservation", "FileObservation", "FolderObservation", "FirewallObservation", "HostObservation",
|
||||
"LinksObservation", "NICObservation", "PortObservation", "NodesObservation", "NestedObservation",
|
||||
"ObservationManager", "ApplicationObservation", "ServiceObservation",]
|
||||
"ObservationManager", "ApplicationObservation", "ServiceObservation", "RouterObservation", "LinkObservation",]
|
||||
# fmt: on
|
||||
|
||||
@@ -24,8 +24,8 @@ class ACLObservation(AbstractObservation, identifier="ACL"):
|
||||
"""List of IP addresses."""
|
||||
wildcard_list: Optional[List[str]] = None
|
||||
"""List of wildcard strings."""
|
||||
port_list: Optional[List[int]] = None
|
||||
"""List of port numbers."""
|
||||
port_list: Optional[List[str]] = None
|
||||
"""List of port names."""
|
||||
protocol_list: Optional[List[str]] = None
|
||||
"""List of protocol names."""
|
||||
num_rules: Optional[int] = None
|
||||
@@ -37,7 +37,7 @@ class ACLObservation(AbstractObservation, identifier="ACL"):
|
||||
num_rules: int,
|
||||
ip_list: List[IPv4Address],
|
||||
wildcard_list: List[str],
|
||||
port_list: List[int],
|
||||
port_list: List[str],
|
||||
protocol_list: List[str],
|
||||
) -> None:
|
||||
"""
|
||||
@@ -51,8 +51,8 @@ class ACLObservation(AbstractObservation, identifier="ACL"):
|
||||
:type ip_list: List[IPv4Address]
|
||||
:param wildcard_list: List of wildcard strings.
|
||||
:type wildcard_list: List[str]
|
||||
:param port_list: List of port numbers.
|
||||
:type port_list: List[int]
|
||||
:param port_list: List of port names.
|
||||
:type port_list: List[str]
|
||||
:param protocol_list: List of protocol names.
|
||||
:type protocol_list: List[str]
|
||||
"""
|
||||
@@ -60,7 +60,7 @@ class ACLObservation(AbstractObservation, identifier="ACL"):
|
||||
self.num_rules: int = num_rules
|
||||
self.ip_to_id: Dict[str, int] = {p: i + 2 for i, p in enumerate(ip_list)}
|
||||
self.wildcard_to_id: Dict[str, int] = {p: i + 2 for i, p in enumerate(wildcard_list)}
|
||||
self.port_to_id: Dict[int, int] = {p: i + 2 for i, p in enumerate(port_list)}
|
||||
self.port_to_id: Dict[str, int] = {p: i + 2 for i, p in enumerate(port_list)}
|
||||
self.protocol_to_id: Dict[str, int] = {p: i + 2 for i, p in enumerate(protocol_list)}
|
||||
self.default_observation: Dict = {
|
||||
i
|
||||
|
||||
@@ -190,6 +190,8 @@ class FolderObservation(AbstractObservation, identifier="FOLDER"):
|
||||
if self.files:
|
||||
self.default_observation["FILES"] = {i + 1: f.default_observation for i, f in enumerate(self.files)}
|
||||
|
||||
self.cached_obs: Optional[ObsType] = self.default_observation
|
||||
|
||||
def observe(self, state: Dict) -> ObsType:
|
||||
"""
|
||||
Generate observation based on the current state of the simulation.
|
||||
@@ -204,7 +206,10 @@ class FolderObservation(AbstractObservation, identifier="FOLDER"):
|
||||
return self.default_observation
|
||||
|
||||
if self.file_system_requires_scan:
|
||||
health_status = folder_state["visible_status"]
|
||||
if not folder_state["scanned_this_step"]:
|
||||
health_status = self.cached_obs["health_status"]
|
||||
else:
|
||||
health_status = folder_state["visible_status"]
|
||||
else:
|
||||
health_status = folder_state["health_status"]
|
||||
|
||||
|
||||
@@ -27,13 +27,13 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"):
|
||||
"""List of IP addresses for encoding ACLs."""
|
||||
wildcard_list: Optional[List[str]] = None
|
||||
"""List of IP wildcards for encoding ACLs."""
|
||||
port_list: Optional[List[int]] = None
|
||||
port_list: Optional[List[str]] = None
|
||||
"""List of ports for encoding ACLs."""
|
||||
protocol_list: Optional[List[str]] = None
|
||||
"""List of protocols for encoding ACLs."""
|
||||
num_rules: Optional[int] = None
|
||||
"""Number of rules ACL rules to show."""
|
||||
include_users: Optional[bool] = True
|
||||
include_users: Optional[bool] = None
|
||||
"""If True, report user session information."""
|
||||
|
||||
def __init__(
|
||||
@@ -41,7 +41,7 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"):
|
||||
where: WhereType,
|
||||
ip_list: List[str],
|
||||
wildcard_list: List[str],
|
||||
port_list: List[int],
|
||||
port_list: List[str],
|
||||
protocol_list: List[str],
|
||||
num_rules: int,
|
||||
include_users: bool,
|
||||
@@ -56,8 +56,8 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"):
|
||||
:type ip_list: List[str]
|
||||
:param wildcard_list: List of wildcard rules.
|
||||
:type wildcard_list: List[str]
|
||||
:param port_list: List of port numbers.
|
||||
:type port_list: List[int]
|
||||
:param port_list: List of port names.
|
||||
:type port_list: List[str]
|
||||
:param protocol_list: List of protocol types.
|
||||
:type protocol_list: List[str]
|
||||
:param num_rules: Number of rules configured in the firewall.
|
||||
@@ -72,7 +72,6 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"):
|
||||
self.ports: List[PortObservation] = [
|
||||
PortObservation(where=self.where + ["NICs", port_num]) for port_num in (1, 2, 3)
|
||||
]
|
||||
# TODO: check what the port nums are for firewall.
|
||||
|
||||
self.internal_inbound_acl = ACLObservation(
|
||||
where=self.where + ["internal_inbound_acl", "acl"],
|
||||
@@ -140,6 +139,8 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"):
|
||||
},
|
||||
},
|
||||
}
|
||||
if self.include_users:
|
||||
self.default_observation["users"] = {"local_login": 0, "remote_sessions": 0}
|
||||
|
||||
def observe(self, state: Dict) -> ObsType:
|
||||
"""
|
||||
@@ -153,29 +154,35 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"):
|
||||
firewall_state = access_from_nested_dict(state, self.where)
|
||||
if firewall_state is NOT_PRESENT_IN_STATE:
|
||||
return self.default_observation
|
||||
obs = {
|
||||
"PORTS": {i + 1: p.observe(state) for i, p in enumerate(self.ports)},
|
||||
"ACL": {
|
||||
"INTERNAL": {
|
||||
"INBOUND": self.internal_inbound_acl.observe(state),
|
||||
"OUTBOUND": self.internal_outbound_acl.observe(state),
|
||||
|
||||
is_on = firewall_state["operating_state"] == 1
|
||||
if not is_on:
|
||||
obs = {**self.default_observation}
|
||||
|
||||
else:
|
||||
obs = {
|
||||
"PORTS": {i + 1: p.observe(state) for i, p in enumerate(self.ports)},
|
||||
"ACL": {
|
||||
"INTERNAL": {
|
||||
"INBOUND": self.internal_inbound_acl.observe(state),
|
||||
"OUTBOUND": self.internal_outbound_acl.observe(state),
|
||||
},
|
||||
"DMZ": {
|
||||
"INBOUND": self.dmz_inbound_acl.observe(state),
|
||||
"OUTBOUND": self.dmz_outbound_acl.observe(state),
|
||||
},
|
||||
"EXTERNAL": {
|
||||
"INBOUND": self.external_inbound_acl.observe(state),
|
||||
"OUTBOUND": self.external_outbound_acl.observe(state),
|
||||
},
|
||||
},
|
||||
"DMZ": {
|
||||
"INBOUND": self.dmz_inbound_acl.observe(state),
|
||||
"OUTBOUND": self.dmz_outbound_acl.observe(state),
|
||||
},
|
||||
"EXTERNAL": {
|
||||
"INBOUND": self.external_inbound_acl.observe(state),
|
||||
"OUTBOUND": self.external_outbound_acl.observe(state),
|
||||
},
|
||||
},
|
||||
}
|
||||
if self.include_users:
|
||||
sess = firewall_state["services"]["UserSessionManager"]
|
||||
obs["users"] = {
|
||||
"local_login": 1 if sess["current_local_user"] else 0,
|
||||
"remote_sessions": min(self.max_users, len(sess["active_remote_sessions"])),
|
||||
}
|
||||
if self.include_users:
|
||||
sess = firewall_state["services"]["UserSessionManager"]
|
||||
obs["users"] = {
|
||||
"local_login": 1 if sess["current_local_user"] else 0,
|
||||
"remote_sessions": min(self.max_users, len(sess["active_remote_sessions"])),
|
||||
}
|
||||
return obs
|
||||
|
||||
@property
|
||||
@@ -186,34 +193,36 @@ class FirewallObservation(AbstractObservation, identifier="FIREWALL"):
|
||||
:return: Gymnasium space representing the observation space for firewall status.
|
||||
:rtype: spaces.Space
|
||||
"""
|
||||
space = spaces.Dict(
|
||||
{
|
||||
"PORTS": spaces.Dict({i + 1: p.space for i, p in enumerate(self.ports)}),
|
||||
"ACL": spaces.Dict(
|
||||
{
|
||||
"INTERNAL": spaces.Dict(
|
||||
{
|
||||
"INBOUND": self.internal_inbound_acl.space,
|
||||
"OUTBOUND": self.internal_outbound_acl.space,
|
||||
}
|
||||
),
|
||||
"DMZ": spaces.Dict(
|
||||
{
|
||||
"INBOUND": self.dmz_inbound_acl.space,
|
||||
"OUTBOUND": self.dmz_outbound_acl.space,
|
||||
}
|
||||
),
|
||||
"EXTERNAL": spaces.Dict(
|
||||
{
|
||||
"INBOUND": self.external_inbound_acl.space,
|
||||
"OUTBOUND": self.external_outbound_acl.space,
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return space
|
||||
shape = {
|
||||
"PORTS": spaces.Dict({i + 1: p.space for i, p in enumerate(self.ports)}),
|
||||
"ACL": spaces.Dict(
|
||||
{
|
||||
"INTERNAL": spaces.Dict(
|
||||
{
|
||||
"INBOUND": self.internal_inbound_acl.space,
|
||||
"OUTBOUND": self.internal_outbound_acl.space,
|
||||
}
|
||||
),
|
||||
"DMZ": spaces.Dict(
|
||||
{
|
||||
"INBOUND": self.dmz_inbound_acl.space,
|
||||
"OUTBOUND": self.dmz_outbound_acl.space,
|
||||
}
|
||||
),
|
||||
"EXTERNAL": spaces.Dict(
|
||||
{
|
||||
"INBOUND": self.external_inbound_acl.space,
|
||||
"OUTBOUND": self.external_outbound_acl.space,
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
if self.include_users:
|
||||
shape["users"] = spaces.Dict(
|
||||
{"local_login": spaces.Discrete(2), "remote_sessions": spaces.Discrete(self.max_users + 1)}
|
||||
)
|
||||
return spaces.Dict(shape)
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: ConfigSchema, parent_where: WhereType = []) -> FirewallObservation:
|
||||
|
||||
@@ -54,7 +54,7 @@ class HostObservation(AbstractObservation, identifier="HOST"):
|
||||
"""
|
||||
If True, files and folders must be scanned to update the health state. If False, true state is always shown.
|
||||
"""
|
||||
include_users: Optional[bool] = True
|
||||
include_users: Optional[bool] = None
|
||||
"""If True, report user session information."""
|
||||
|
||||
def __init__(
|
||||
@@ -191,25 +191,31 @@ class HostObservation(AbstractObservation, identifier="HOST"):
|
||||
if node_state is NOT_PRESENT_IN_STATE:
|
||||
return self.default_observation
|
||||
|
||||
obs = {}
|
||||
is_on = node_state["operating_state"] == 1
|
||||
if not is_on:
|
||||
obs = {**self.default_observation}
|
||||
|
||||
else:
|
||||
obs = {}
|
||||
if self.services:
|
||||
obs["SERVICES"] = {i + 1: service.observe(state) for i, service in enumerate(self.services)}
|
||||
if self.applications:
|
||||
obs["APPLICATIONS"] = {i + 1: app.observe(state) for i, app in enumerate(self.applications)}
|
||||
if self.folders:
|
||||
obs["FOLDERS"] = {i + 1: folder.observe(state) for i, folder in enumerate(self.folders)}
|
||||
if self.nics:
|
||||
obs["NICS"] = {i + 1: nic.observe(state) for i, nic in enumerate(self.nics)}
|
||||
if self.include_num_access:
|
||||
obs["num_file_creations"] = node_state["file_system"]["num_file_creations"]
|
||||
obs["num_file_deletions"] = node_state["file_system"]["num_file_deletions"]
|
||||
if self.include_users:
|
||||
sess = node_state["services"]["UserSessionManager"]
|
||||
obs["users"] = {
|
||||
"local_login": 1 if sess["current_local_user"] else 0,
|
||||
"remote_sessions": min(self.max_users, len(sess["active_remote_sessions"])),
|
||||
}
|
||||
|
||||
obs["operating_status"] = node_state["operating_state"]
|
||||
if self.services:
|
||||
obs["SERVICES"] = {i + 1: service.observe(state) for i, service in enumerate(self.services)}
|
||||
if self.applications:
|
||||
obs["APPLICATIONS"] = {i + 1: app.observe(state) for i, app in enumerate(self.applications)}
|
||||
if self.folders:
|
||||
obs["FOLDERS"] = {i + 1: folder.observe(state) for i, folder in enumerate(self.folders)}
|
||||
if self.nics:
|
||||
obs["NICS"] = {i + 1: nic.observe(state) for i, nic in enumerate(self.nics)}
|
||||
if self.include_num_access:
|
||||
obs["num_file_creations"] = node_state["file_system"]["num_file_creations"]
|
||||
obs["num_file_deletions"] = node_state["file_system"]["num_file_deletions"]
|
||||
if self.include_users:
|
||||
sess = node_state["services"]["UserSessionManager"]
|
||||
obs["users"] = {
|
||||
"local_login": 1 if sess["current_local_user"] else 0,
|
||||
"remote_sessions": min(self.max_users, len(sess["active_remote_sessions"])),
|
||||
}
|
||||
return obs
|
||||
|
||||
@property
|
||||
|
||||
@@ -56,7 +56,7 @@ class NodesObservation(AbstractObservation, identifier="NODES"):
|
||||
"""List of IP addresses for encoding ACLs."""
|
||||
wildcard_list: Optional[List[str]] = None
|
||||
"""List of IP wildcards for encoding ACLs."""
|
||||
port_list: Optional[List[int]] = None
|
||||
port_list: Optional[List[str]] = None
|
||||
"""List of ports for encoding ACLs."""
|
||||
protocol_list: Optional[List[str]] = None
|
||||
"""List of protocols for encoding ACLs."""
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import cached_property
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from gymnasium import spaces
|
||||
from gymnasium.core import ObsType
|
||||
from pydantic import BaseModel, ConfigDict, model_validator, ValidationError
|
||||
from pydantic import BaseModel, computed_field, ConfigDict, Field, model_validator, ValidationError
|
||||
|
||||
from primaite.game.agent.observations.observations import AbstractObservation, WhereType
|
||||
|
||||
@@ -140,7 +141,7 @@ class NullObservation(AbstractObservation, identifier="NONE"):
|
||||
return cls()
|
||||
|
||||
|
||||
class ObservationManager:
|
||||
class ObservationManager(BaseModel):
|
||||
"""
|
||||
Manage the observations of an Agent.
|
||||
|
||||
@@ -150,15 +151,66 @@ class ObservationManager:
|
||||
3. Formatting this information so an agent can use it to make decisions.
|
||||
"""
|
||||
|
||||
def __init__(self, obs: AbstractObservation) -> None:
|
||||
"""Initialise observation space.
|
||||
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||
|
||||
:param observation: Observation object
|
||||
:type observation: AbstractObservation
|
||||
"""
|
||||
self.obs: AbstractObservation = obs
|
||||
self.current_observation: ObsType
|
||||
"""Cached copy of the observation at the time it was most recently calculated."""
|
||||
class ConfigSchema(BaseModel):
|
||||
"""Config Schema for Observation Manager."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
type: str = "NONE"
|
||||
"""Identifier name for the top-level observation."""
|
||||
options: AbstractObservation.ConfigSchema = Field(
|
||||
default_factory=lambda: NullObservation.ConfigSchema(), validate_default=True
|
||||
)
|
||||
"""Options to pass into the top-level observation during creation."""
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def resolve_obs_options_type(cls, data: Any) -> Any:
|
||||
"""
|
||||
When constructing the model from a dict, resolve the correct observation class based on `type` field.
|
||||
|
||||
Workaround: The `options` field is statically typed as AbstractObservation. Therefore, it falls over when
|
||||
passing in data that adheres to a subclass schema rather than the plain AbstractObservation schema. There is
|
||||
a way to do this properly using discriminated union, but most advice on the internet assumes that the full
|
||||
list of types between which to discriminate is known ahead-of-time. That is not the case for us, because of
|
||||
our plugin architecture.
|
||||
|
||||
We may be able to revisit and implement a better solution when needed using the following resources as
|
||||
research starting points:
|
||||
https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions
|
||||
https://github.com/pydantic/pydantic/issues/7366
|
||||
https://github.com/pydantic/pydantic/issues/7462
|
||||
https://github.com/pydantic/pydantic/pull/7983
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
# (TODO: duplicate default definition between here and the actual model)
|
||||
obs_type = data["type"] if "type" in data else "NONE"
|
||||
obs_class = AbstractObservation._registry[obs_type]
|
||||
|
||||
# if no options are passed in, try to create a default schema. Only works if there are no mandatory fields
|
||||
if "options" not in data:
|
||||
data["options"] = obs_class.ConfigSchema()
|
||||
|
||||
# if options passed as a dict, validate against schema
|
||||
elif isinstance(data["options"], dict):
|
||||
data["options"] = obs_class.ConfigSchema(**data["options"])
|
||||
|
||||
return data
|
||||
|
||||
config: ConfigSchema = Field(default_factory=lambda: ObservationManager.ConfigSchema())
|
||||
|
||||
current_observation: ObsType = 0
|
||||
|
||||
@computed_field
|
||||
@cached_property
|
||||
def obs(self) -> AbstractObservation:
|
||||
"""Create the main observation component for the observation manager from the config."""
|
||||
obs_class = AbstractObservation._registry[self.config.type]
|
||||
obs_instance = obs_class.from_config(config=self.config.options)
|
||||
return obs_instance
|
||||
|
||||
def update(self, state: Dict) -> Dict:
|
||||
"""
|
||||
|
||||
@@ -31,7 +31,7 @@ class AbstractObservation(ABC):
|
||||
"""Initialise an observation. This method must be overwritten."""
|
||||
self.default_observation: ObsType
|
||||
|
||||
def __init_subclass__(cls, identifier: str, **kwargs: Any) -> None:
|
||||
def __init_subclass__(cls, identifier: Optional[str] = None, **kwargs: Any) -> None:
|
||||
"""
|
||||
Register an observation type.
|
||||
|
||||
@@ -40,6 +40,8 @@ class AbstractObservation(ABC):
|
||||
:raises ValueError: When attempting to create a component with a name that is already in use.
|
||||
"""
|
||||
super().__init_subclass__(**kwargs)
|
||||
if identifier is None:
|
||||
return
|
||||
if identifier in cls._registry:
|
||||
raise ValueError(f"Duplicate observation component type {identifier}")
|
||||
cls._registry[identifier] = cls
|
||||
|
||||
@@ -33,13 +33,13 @@ class RouterObservation(AbstractObservation, identifier="ROUTER"):
|
||||
"""List of IP addresses for encoding ACLs."""
|
||||
wildcard_list: Optional[List[str]] = None
|
||||
"""List of IP wildcards for encoding ACLs."""
|
||||
port_list: Optional[List[int]] = None
|
||||
port_list: Optional[List[str]] = None
|
||||
"""List of ports for encoding ACLs."""
|
||||
protocol_list: Optional[List[str]] = None
|
||||
"""List of protocols for encoding ACLs."""
|
||||
num_rules: Optional[int] = None
|
||||
"""Number of rules ACL rules to show."""
|
||||
include_users: Optional[bool] = True
|
||||
include_users: Optional[bool] = None
|
||||
"""If True, report user session information."""
|
||||
|
||||
def __init__(
|
||||
@@ -84,6 +84,8 @@ class RouterObservation(AbstractObservation, identifier="ROUTER"):
|
||||
}
|
||||
if self.ports:
|
||||
self.default_observation["PORTS"] = {i + 1: p.default_observation for i, p in enumerate(self.ports)}
|
||||
if self.include_users:
|
||||
self.default_observation["users"] = {"local_login": 0, "remote_sessions": 0}
|
||||
|
||||
def observe(self, state: Dict) -> ObsType:
|
||||
"""
|
||||
@@ -98,16 +100,21 @@ class RouterObservation(AbstractObservation, identifier="ROUTER"):
|
||||
if router_state is NOT_PRESENT_IN_STATE:
|
||||
return self.default_observation
|
||||
|
||||
obs = {}
|
||||
obs["ACL"] = self.acl.observe(state)
|
||||
if self.ports:
|
||||
obs["PORTS"] = {i + 1: p.observe(state) for i, p in enumerate(self.ports)}
|
||||
if self.include_users:
|
||||
sess = router_state["services"]["UserSessionManager"]
|
||||
obs["users"] = {
|
||||
"local_login": 1 if sess["current_local_user"] else 0,
|
||||
"remote_sessions": min(self.max_users, len(sess["active_remote_sessions"])),
|
||||
}
|
||||
is_on = router_state["operating_state"] == 1
|
||||
if not is_on:
|
||||
obs = {**self.default_observation}
|
||||
|
||||
else:
|
||||
obs = {}
|
||||
obs["ACL"] = self.acl.observe(state)
|
||||
if self.ports:
|
||||
obs["PORTS"] = {i + 1: p.observe(state) for i, p in enumerate(self.ports)}
|
||||
if self.include_users:
|
||||
sess = router_state["services"]["UserSessionManager"]
|
||||
obs["users"] = {
|
||||
"local_login": 1 if sess["current_local_user"] else 0,
|
||||
"remote_sessions": min(self.max_users, len(sess["active_remote_sessions"])),
|
||||
}
|
||||
return obs
|
||||
|
||||
@property
|
||||
@@ -121,6 +128,10 @@ class RouterObservation(AbstractObservation, identifier="ROUTER"):
|
||||
shape = {"ACL": self.acl.space}
|
||||
if self.ports:
|
||||
shape["PORTS"] = spaces.Dict({i + 1: p.space for i, p in enumerate(self.ports)})
|
||||
if self.include_users:
|
||||
shape["users"] = spaces.Dict(
|
||||
{"local_login": spaces.Discrete(2), "remote_sessions": spaces.Discrete(self.max_users + 1)}
|
||||
)
|
||||
return spaces.Dict(shape)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -30,7 +30,7 @@ the structure:
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Callable, ClassVar, Dict, Iterable, List, Optional, Tuple, Type, TYPE_CHECKING, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from typing_extensions import Never
|
||||
|
||||
from primaite import getLogger
|
||||
@@ -48,21 +48,17 @@ class AbstractReward(BaseModel):
|
||||
|
||||
config: "AbstractReward.ConfigSchema"
|
||||
|
||||
# def __init__(self, schema_name, **kwargs):
|
||||
# super.__init__(self, **kwargs)
|
||||
# # Create ConfigSchema class
|
||||
# self.config_class = type(schema_name, (BaseModel, ABC), **kwargs)
|
||||
# self.config = self.config_class()
|
||||
|
||||
class ConfigSchema(BaseModel, ABC):
|
||||
"""Config schema for AbstractReward."""
|
||||
|
||||
type: str
|
||||
type: str = ""
|
||||
|
||||
_registry: ClassVar[Dict[str, Type["AbstractReward"]]] = {}
|
||||
|
||||
def __init_subclass__(cls, identifier: str, **kwargs: Any) -> None:
|
||||
def __init_subclass__(cls, identifier: Optional[str] = None, **kwargs: Any) -> None:
|
||||
super().__init_subclass__(**kwargs)
|
||||
if identifier is None:
|
||||
return
|
||||
if identifier in cls._registry:
|
||||
raise ValueError(f"Duplicate reward {identifier}")
|
||||
cls._registry[identifier] = cls
|
||||
@@ -381,14 +377,19 @@ class SharedReward(AbstractReward, identifier="SHARED_REWARD"):
|
||||
|
||||
|
||||
class ActionPenalty(AbstractReward, identifier="ACTION_PENALTY"):
|
||||
"""Apply a negative reward when taking any action except DONOTHING."""
|
||||
"""Apply a negative reward when taking any action except do_nothing."""
|
||||
|
||||
config: "ActionPenalty.ConfigSchema"
|
||||
|
||||
class ConfigSchema(AbstractReward.ConfigSchema):
|
||||
"""Config schema for ActionPenalty."""
|
||||
"""Config schema for ActionPenalty.
|
||||
|
||||
:param action_penalty: Reward to give agents for taking any action except do_nothing
|
||||
:type action_penalty: float
|
||||
:param do_nothing_penalty: Reward to give agent for taking the do_nothing action
|
||||
:type do_nothing_penalty: float
|
||||
"""
|
||||
|
||||
type: str = "ACTION_PENALTY"
|
||||
action_penalty: float = -1.0
|
||||
do_nothing_penalty: float = 0.0
|
||||
|
||||
@@ -402,21 +403,81 @@ class ActionPenalty(AbstractReward, identifier="ACTION_PENALTY"):
|
||||
:return: Reward value
|
||||
:rtype: float
|
||||
"""
|
||||
if last_action_response.action == "DONOTHING":
|
||||
if last_action_response.action == "do_nothing":
|
||||
return self.config.do_nothing_penalty
|
||||
|
||||
else:
|
||||
return self.config.action_penalty
|
||||
|
||||
|
||||
class RewardFunction:
|
||||
class _SingleComponentConfig(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
type: str
|
||||
options: AbstractReward.ConfigSchema
|
||||
weight: float = 1.0
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def resolve_obs_options_type(cls, data: Any) -> Any:
|
||||
"""
|
||||
When constructing the model from a dict, resolve the correct reward class based on `type` field.
|
||||
|
||||
Workaround: The `options` field is statically typed as AbstractReward. Therefore, it falls over when
|
||||
passing in data that adheres to a subclass schema rather than the plain AbstractReward schema. There is
|
||||
a way to do this properly using discriminated union, but most advice on the internet assumes that the full
|
||||
list of types between which to discriminate is known ahead-of-time. That is not the case for us, because of
|
||||
our plugin architecture.
|
||||
|
||||
We may be able to revisit and implement a better solution when needed using the following resources as
|
||||
research starting points:
|
||||
https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions
|
||||
https://github.com/pydantic/pydantic/issues/7366
|
||||
https://github.com/pydantic/pydantic/issues/7462
|
||||
https://github.com/pydantic/pydantic/pull/7983
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
|
||||
assert "type" in data, ValueError('Reward component definition is missing the "type" key.')
|
||||
rew_type = data["type"]
|
||||
rew_class = AbstractReward._registry[rew_type]
|
||||
|
||||
# if no options are passed in, try to create a default schema. Only works if there are no mandatory fields.
|
||||
if "options" not in data:
|
||||
data["options"] = rew_class.ConfigSchema()
|
||||
|
||||
# if options are passed as a dict, validate against schema
|
||||
elif isinstance(data["options"], dict):
|
||||
data["options"] = rew_class.ConfigSchema(**data["options"])
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class RewardFunction(BaseModel):
|
||||
"""Manages the reward function for the agent."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialise the reward function object."""
|
||||
self.reward_components: List[Tuple[AbstractReward, float]] = []
|
||||
"attribute reward_components keeps track of reward components and the weights assigned to each."
|
||||
self.current_reward: float = 0.0
|
||||
self.total_reward: float = 0.0
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
class ConfigSchema(BaseModel):
|
||||
"""Config Schema for RewardFunction."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reward_components: Iterable[_SingleComponentConfig] = []
|
||||
|
||||
config: ConfigSchema = Field(default_factory=lambda: RewardFunction.ConfigSchema())
|
||||
|
||||
reward_components: List[Tuple[AbstractReward, float]] = []
|
||||
|
||||
current_reward: float = 0.0
|
||||
total_reward: float = 0.0
|
||||
|
||||
def __init__(self, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
for rew_config in self.config.reward_components:
|
||||
rew_class = AbstractReward._registry[rew_config.type]
|
||||
rew_instance = rew_class(config=rew_config.options)
|
||||
self.register_component(component=rew_instance, weight=rew_config.weight)
|
||||
|
||||
def register_component(self, component: AbstractReward, weight: float = 1.0) -> None:
|
||||
"""Add a reward component to the reward function.
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
|
||||
from primaite.game.agent import interface
|
||||
from primaite.game.agent.scripted_agents import abstract_tap, data_manipulation_bot, probabilistic_agent, random_agent
|
||||
|
||||
__all__ = ("abstract_tap", "data_manipulation_bot", "interface", "probabilistic_agent", "random_agent")
|
||||
|
||||
61
src/primaite/game/agent/scripted_agents/abstract_tap.py
Normal file
61
src/primaite/game/agent/scripted_agents/abstract_tap.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from abc import abstractmethod
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from gymnasium.core import ObsType
|
||||
from pydantic import Field
|
||||
|
||||
from primaite.game.agent.scripted_agents.random_agent import PeriodicAgent
|
||||
|
||||
__all__ = "AbstractTAPAgent"
|
||||
|
||||
|
||||
class AbstractTAPAgent(PeriodicAgent, identifier="AbstractTAP"):
|
||||
"""Base class for TAP agents to inherit from."""
|
||||
|
||||
config: "AbstractTAPAgent.ConfigSchema" = Field(default_factory=lambda: AbstractTAPAgent.ConfigSchema())
|
||||
next_execution_timestep: int = 0
|
||||
|
||||
class AgentSettingsSchema(PeriodicAgent.AgentSettingsSchema):
|
||||
"""Schema for the `agent_settings` part of the agent config."""
|
||||
|
||||
possible_starting_nodes: List[str] = Field(default_factory=list)
|
||||
|
||||
class ConfigSchema(PeriodicAgent.ConfigSchema):
|
||||
"""Configuration schema for Abstract TAP agents."""
|
||||
|
||||
type: str = "AbstractTAP"
|
||||
agent_settings: AbstractTAPAgent.AgentSettingsSchema = Field(
|
||||
default_factory=lambda: AbstractTAPAgent.AgentSettingsSchema()
|
||||
)
|
||||
|
||||
starting_node: Optional[str] = None
|
||||
|
||||
@abstractmethod
|
||||
def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]:
|
||||
"""Return an action to be taken in the environment."""
|
||||
return super().get_action(obs=obs, timestep=timestep)
|
||||
|
||||
@abstractmethod
|
||||
def setup_agent(self) -> None:
|
||||
"""Set up agent."""
|
||||
pass
|
||||
|
||||
def _set_next_execution_timestep(self, timestep: int) -> None:
|
||||
"""Set the next execution timestep with a configured random variance.
|
||||
|
||||
:param timestep: The timestep to add variance to.
|
||||
"""
|
||||
random_timestep_increment = random.randint(
|
||||
-self.config.agent_settings.variance, self.config.agent_settings.variance
|
||||
)
|
||||
self.next_execution_timestep = timestep + random_timestep_increment
|
||||
|
||||
def _select_start_node(self) -> None:
|
||||
"""Set the starting starting node of the agent to be a random node from this agent's action manager."""
|
||||
# we are assuming that every node in the node manager has a data manipulation application at idx 0
|
||||
self.starting_node = random.choice(self.config.agent_settings.possible_starting_nodes)
|
||||
self.logger.debug(f"Selected starting node: {self.starting_node}")
|
||||
@@ -1,31 +1,35 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
import random
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from gymnasium.core import ObsType
|
||||
from pydantic import Field
|
||||
|
||||
from primaite.game.agent.interface import AbstractScriptedAgent
|
||||
from primaite.game.agent.scripted_agents.random_agent import PeriodicAgent
|
||||
|
||||
__all__ = "DataManipulationAgent"
|
||||
|
||||
|
||||
class DataManipulationAgent(AbstractScriptedAgent):
|
||||
class DataManipulationAgent(PeriodicAgent, identifier="RedDatabaseCorruptingAgent"):
|
||||
"""Agent that uses a DataManipulationBot to perform an SQL injection attack."""
|
||||
|
||||
next_execution_timestep: int = 0
|
||||
starting_node_idx: int = 0
|
||||
class AgentSettingsSchema(PeriodicAgent.AgentSettingsSchema):
|
||||
"""Schema for the `agent_settings` part of the agent config."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setup_agent()
|
||||
target_application: str = "DataManipulationBot"
|
||||
|
||||
def _set_next_execution_timestep(self, timestep: int) -> None:
|
||||
"""Set the next execution timestep with a configured random variance.
|
||||
class ConfigSchema(PeriodicAgent.ConfigSchema):
|
||||
"""Configuration Schema for DataManipulationAgent."""
|
||||
|
||||
:param timestep: The timestep to add variance to.
|
||||
"""
|
||||
random_timestep_increment = random.randint(
|
||||
-self.agent_settings.start_settings.variance, self.agent_settings.start_settings.variance
|
||||
type: str = "RedDatabaseCorruptingAgent"
|
||||
agent_settings: "DataManipulationAgent.AgentSettingsSchema" = Field(
|
||||
default_factory=lambda: DataManipulationAgent.AgentSettingsSchema()
|
||||
)
|
||||
self.next_execution_timestep = timestep + random_timestep_increment
|
||||
|
||||
config: "DataManipulationAgent.ConfigSchema" = Field(default_factory=lambda: DataManipulationAgent.ConfigSchema())
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._set_next_execution_timestep(timestep=self.config.agent_settings.start_step, variance=0)
|
||||
|
||||
def get_action(self, obs: ObsType, timestep: int) -> Tuple[str, Dict]:
|
||||
"""Waits until a specific timestep, then attempts to execute its data manipulation application.
|
||||
@@ -38,21 +42,14 @@ class DataManipulationAgent(AbstractScriptedAgent):
|
||||
:rtype: Tuple[str, Dict]
|
||||
"""
|
||||
if timestep < self.next_execution_timestep:
|
||||
self.logger.debug(msg="Performing do NOTHING")
|
||||
return "DONOTHING", {}
|
||||
self.logger.debug(msg="Performing do nothing action")
|
||||
return "do_nothing", {}
|
||||
|
||||
self._set_next_execution_timestep(timestep + self.agent_settings.start_settings.frequency)
|
||||
self._set_next_execution_timestep(
|
||||
timestep=timestep + self.config.agent_settings.frequency, variance=self.config.agent_settings.variance
|
||||
)
|
||||
self.logger.info(msg="Performing a data manipulation attack!")
|
||||
return "NODE_APPLICATION_EXECUTE", {"node_id": self.starting_node_idx, "application_id": 0}
|
||||
|
||||
def setup_agent(self) -> None:
|
||||
"""Set the next execution timestep when the episode resets."""
|
||||
self._select_start_node()
|
||||
self._set_next_execution_timestep(self.agent_settings.start_settings.start_step)
|
||||
|
||||
def _select_start_node(self) -> None:
|
||||
"""Set the starting starting node of the agent to be a random node from this agent's action manager."""
|
||||
# we are assuming that every node in the node manager has a data manipulation application at idx 0
|
||||
num_nodes = len(self.action_manager.node_names)
|
||||
self.starting_node_idx = random.randint(0, num_nodes - 1)
|
||||
self.logger.debug(msg=f"Select Start Node ID: {self.starting_node_idx}")
|
||||
return "node_application_execute", {
|
||||
"node_name": self.start_node,
|
||||
"application_name": self.config.agent_settings.target_application,
|
||||
}
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
"""Agents with predefined behaviours."""
|
||||
from typing import Dict, Optional, Tuple
|
||||
from typing import Dict, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pydantic
|
||||
from gymnasium.core import ObsType
|
||||
from numpy.random import Generator
|
||||
from pydantic import Field
|
||||
|
||||
from primaite.game.agent.actions import ActionManager
|
||||
from primaite.game.agent.interface import AbstractScriptedAgent
|
||||
from primaite.game.agent.observations.observation_manager import ObservationManager
|
||||
from primaite.game.agent.rewards import RewardFunction
|
||||
|
||||
__all__ = "ProbabilisticAgent"
|
||||
|
||||
|
||||
class ProbabilisticAgent(AbstractScriptedAgent):
|
||||
class ProbabilisticAgent(AbstractScriptedAgent, identifier="ProbabilisticAgent"):
|
||||
"""Scripted agent which randomly samples its action space with prescribed probabilities for each action."""
|
||||
|
||||
class Settings(pydantic.BaseModel):
|
||||
"""Config schema for Probabilistic agent settings."""
|
||||
rng: Generator = Field(default_factory=lambda: np.random.default_rng(np.random.randint(0, 65535)))
|
||||
|
||||
model_config = pydantic.ConfigDict(extra="forbid")
|
||||
"""Strict validation."""
|
||||
action_probabilities: Dict[int, float]
|
||||
class AgentSettingsSchema(AbstractScriptedAgent.AgentSettingsSchema):
|
||||
"""Schema for the `agent_settings` part of the agent config."""
|
||||
|
||||
action_probabilities: Dict[int, float] = None
|
||||
"""Probability to perform each action in the action map. The sum of probabilities should sum to 1."""
|
||||
# TODO: give the option to still set a random seed, but have it vary each episode in a predictable way
|
||||
# for example if the user sets seed 123, have it be 123 + episode_num, so that each ep it's the next seed.
|
||||
|
||||
@pydantic.field_validator("action_probabilities", mode="after")
|
||||
@classmethod
|
||||
@@ -44,31 +43,20 @@ class ProbabilisticAgent(AbstractScriptedAgent):
|
||||
)
|
||||
return v
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_name: str,
|
||||
action_space: Optional[ActionManager],
|
||||
observation_space: Optional[ObservationManager],
|
||||
reward_function: Optional[RewardFunction],
|
||||
settings: Dict = {},
|
||||
) -> None:
|
||||
# If the action probabilities are not specified, create equal probabilities for all actions
|
||||
if "action_probabilities" not in settings:
|
||||
num_actions = len(action_space.action_map)
|
||||
settings = {"action_probabilities": {i: 1 / num_actions for i in range(num_actions)}}
|
||||
class ConfigSchema(AbstractScriptedAgent.ConfigSchema):
|
||||
"""Configuration schema for Probabilistic Agent."""
|
||||
|
||||
# The random number seed for np.random is dependent on whether a random number seed is set
|
||||
# in the config file. If there is one it is processed by set_random_seed() in environment.py
|
||||
# and as a consequence the the sequence of rng_seed's used here will be repeatable.
|
||||
self.settings = ProbabilisticAgent.Settings(**settings)
|
||||
rng_seed = np.random.randint(0, 65535)
|
||||
self.rng = np.random.default_rng(rng_seed)
|
||||
type: str = "ProbabilisticAgent"
|
||||
agent_settings: "ProbabilisticAgent.AgentSettingsSchema" = Field(
|
||||
default_factory=lambda: ProbabilisticAgent.AgentSettingsSchema()
|
||||
)
|
||||
|
||||
# convert probabilities from
|
||||
self.probabilities = np.asarray(list(self.settings.action_probabilities.values()))
|
||||
config: "ProbabilisticAgent.ConfigSchema" = Field(default_factory=lambda: ProbabilisticAgent.ConfigSchema())
|
||||
|
||||
super().__init__(agent_name, action_space, observation_space, reward_function)
|
||||
self.logger.debug(f"ProbabilisticAgent RNG seed: {rng_seed}")
|
||||
@property
|
||||
def probabilities(self) -> Dict[str, int]:
|
||||
"""Convenience method to view the probabilities of the Agent."""
|
||||
return np.asarray(list(self.config.agent_settings.action_probabilities.values()))
|
||||
|
||||
def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]:
|
||||
"""
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
import random
|
||||
from typing import Dict, Optional, Tuple
|
||||
from functools import cached_property
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from gymnasium.core import ObsType
|
||||
from pydantic import BaseModel
|
||||
from pydantic import computed_field, Field, model_validator
|
||||
|
||||
from primaite.game.agent.actions import ActionManager
|
||||
from primaite.game.agent.interface import AbstractScriptedAgent
|
||||
from primaite.game.agent.observations.observation_manager import ObservationManager
|
||||
from primaite.game.agent.rewards import RewardFunction
|
||||
|
||||
__all__ = ("RandomAgent", "PeriodicAgent")
|
||||
|
||||
|
||||
class RandomAgent(AbstractScriptedAgent):
|
||||
class RandomAgent(AbstractScriptedAgent, identifier="RandomAgent"):
|
||||
"""Agent that ignores its observation and acts completely at random."""
|
||||
|
||||
def get_action(self, obs: ObsType, timestep: int = 0) -> Tuple[str, Dict]:
|
||||
config: "RandomAgent.ConfigSchema" = Field(default_factory=lambda: RandomAgent.ConfigSchema())
|
||||
|
||||
class ConfigSchema(AbstractScriptedAgent.ConfigSchema):
|
||||
"""Configuration Schema for Random Agents."""
|
||||
|
||||
type: str = "RandomAgent"
|
||||
|
||||
def get_action(self) -> Tuple[str, Dict]:
|
||||
"""Sample the action space randomly.
|
||||
|
||||
:param obs: Current observation for this agent, not used in RandomAgent
|
||||
@@ -27,41 +34,60 @@ class RandomAgent(AbstractScriptedAgent):
|
||||
return self.action_manager.get_action(self.action_manager.space.sample())
|
||||
|
||||
|
||||
class PeriodicAgent(AbstractScriptedAgent):
|
||||
class PeriodicAgent(AbstractScriptedAgent, identifier="PeriodicAgent"):
|
||||
"""Agent that does nothing most of the time, but executes application at regular intervals (with variance)."""
|
||||
|
||||
class Settings(BaseModel):
|
||||
"""Configuration values for when an agent starts performing actions."""
|
||||
config: "PeriodicAgent.ConfigSchema" = Field(default_factory=lambda: PeriodicAgent.ConfigSchema())
|
||||
|
||||
start_step: int = 20
|
||||
"The timestep at which an agent begins performing it's actions."
|
||||
start_variance: int = 5
|
||||
"Deviation around the start step."
|
||||
class AgentSettingsSchema(AbstractScriptedAgent.AgentSettingsSchema):
|
||||
"""Schema for the `agent_settings` part of the agent config."""
|
||||
|
||||
start_step: int = 5
|
||||
"The timestep at which an agent begins performing it's actions"
|
||||
frequency: int = 5
|
||||
"The number of timesteps to wait between performing actions."
|
||||
"The number of timesteps to wait between performing actions"
|
||||
variance: int = 0
|
||||
"The amount the frequency can randomly change to."
|
||||
max_executions: int = 999999
|
||||
"Maximum number of times the agent can execute its action."
|
||||
"The amount the frequency can randomly change to"
|
||||
possible_start_nodes: List[str]
|
||||
target_application: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent_name: str,
|
||||
action_space: ActionManager,
|
||||
observation_space: ObservationManager,
|
||||
reward_function: RewardFunction,
|
||||
settings: Optional[Settings] = None,
|
||||
) -> None:
|
||||
"""Initialise PeriodicAgent."""
|
||||
super().__init__(
|
||||
agent_name=agent_name,
|
||||
action_space=action_space,
|
||||
observation_space=observation_space,
|
||||
reward_function=reward_function,
|
||||
@model_validator(mode="after")
|
||||
def check_variance_lt_frequency(self) -> "PeriodicAgent.ConfigSchema":
|
||||
"""
|
||||
Make sure variance is equal to or lower than frequency.
|
||||
|
||||
This is because the calculation for the next execution time is now + (frequency +- variance).
|
||||
If variance were greater than frequency, sometimes the bracketed term would be negative
|
||||
and the attack would never happen again.
|
||||
"""
|
||||
if self.variance >= self.frequency:
|
||||
raise ValueError(
|
||||
f"Agent start settings error: variance must be lower than frequency "
|
||||
f"{self.variance=}, {self.frequency=}"
|
||||
)
|
||||
return self
|
||||
|
||||
class ConfigSchema(AbstractScriptedAgent.ConfigSchema):
|
||||
"""Configuration Schema for Periodic Agent."""
|
||||
|
||||
type: str = "PeriodicAgent"
|
||||
"""Name of the agent."""
|
||||
agent_settings: "PeriodicAgent.AgentSettingsSchema" = Field(
|
||||
default_factory=lambda: PeriodicAgent.AgentSettingsSchema()
|
||||
)
|
||||
self.settings = settings or PeriodicAgent.Settings()
|
||||
self._set_next_execution_timestep(timestep=self.settings.start_step, variance=self.settings.start_variance)
|
||||
self.num_executions = 0
|
||||
|
||||
max_executions: int = 999999
|
||||
"Maximum number of times the agent can execute its action."
|
||||
num_executions: int = 0
|
||||
"""Number of times the agent has executed an action."""
|
||||
next_execution_timestep: int = 0
|
||||
"""Timestep of the next action execution by the agent."""
|
||||
|
||||
@computed_field
|
||||
@cached_property
|
||||
def start_node(self) -> str:
|
||||
"""On instantiation, randomly select a start node."""
|
||||
return random.choice(self.config.agent_settings.possible_start_nodes)
|
||||
|
||||
def _set_next_execution_timestep(self, timestep: int, variance: int) -> None:
|
||||
"""Set the next execution timestep with a configured random variance.
|
||||
@@ -76,9 +102,14 @@ class PeriodicAgent(AbstractScriptedAgent):
|
||||
|
||||
def get_action(self, obs: ObsType, timestep: int) -> Tuple[str, Dict]:
|
||||
"""Do nothing, unless the current timestep is the next execution timestep, in which case do the action."""
|
||||
if timestep == self.next_execution_timestep and self.num_executions < self.settings.max_executions:
|
||||
if timestep == self.next_execution_timestep and self.num_executions < self.max_executions:
|
||||
self.num_executions += 1
|
||||
self._set_next_execution_timestep(timestep + self.settings.frequency, self.settings.variance)
|
||||
return "NODE_APPLICATION_EXECUTE", {"node_id": 0, "application_id": 0}
|
||||
self._set_next_execution_timestep(
|
||||
timestep + self.config.agent_settings.frequency, self.config.agent_settings.variance
|
||||
)
|
||||
return "node_application_execute", {
|
||||
"node_name": self.start_node,
|
||||
"application_name": self.config.agent_settings.target_application,
|
||||
}
|
||||
|
||||
return "DONOTHING", {}
|
||||
return "do_nothing", {}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# © Crown-owned copyright 2025, Defence Science and Technology Laboratory UK
|
||||
import random
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from gymnasium.core import ObsType
|
||||
|
||||
from primaite.game.agent.interface import AbstractScriptedAgent
|
||||
|
||||
|
||||
class TAP001(AbstractScriptedAgent):
|
||||
"""
|
||||
TAP001 | Mobile Malware -- Ransomware Variant.
|
||||
|
||||
Scripted Red Agent. Capable of one action; launching the kill-chain (Ransomware Application)
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setup_agent()
|
||||
|
||||
next_execution_timestep: int = 0
|
||||
starting_node_idx: int = 0
|
||||
installed: bool = False
|
||||
|
||||
def _set_next_execution_timestep(self, timestep: int) -> None:
|
||||
"""Set the next execution timestep with a configured random variance.
|
||||
|
||||
:param timestep: The timestep to add variance to.
|
||||
"""
|
||||
random_timestep_increment = random.randint(
|
||||
-self.agent_settings.start_settings.variance, self.agent_settings.start_settings.variance
|
||||
)
|
||||
self.next_execution_timestep = timestep + random_timestep_increment
|
||||
|
||||
def get_action(self, obs: ObsType, timestep: int) -> Tuple[str, Dict]:
|
||||
"""Waits until a specific timestep, then attempts to execute the ransomware application.
|
||||
|
||||
This application acts a wrapper around the kill-chain, similar to green-analyst and
|
||||
the previous UC2 data manipulation bot.
|
||||
|
||||
:param obs: Current observation for this agent.
|
||||
:type obs: ObsType
|
||||
:param timestep: The current simulation timestep, used for scheduling actions
|
||||
:type timestep: int
|
||||
:return: Action formatted in CAOS format
|
||||
:rtype: Tuple[str, Dict]
|
||||
"""
|
||||
if timestep < self.next_execution_timestep:
|
||||
return "DONOTHING", {}
|
||||
|
||||
self._set_next_execution_timestep(timestep + self.agent_settings.start_settings.frequency)
|
||||
|
||||
if not self.installed:
|
||||
self.installed = True
|
||||
return "NODE_APPLICATION_INSTALL", {
|
||||
"node_id": self.starting_node_idx,
|
||||
"application_name": "RansomwareScript",
|
||||
}
|
||||
|
||||
return "NODE_APPLICATION_EXECUTE", {"node_id": self.starting_node_idx, "application_id": 0}
|
||||
|
||||
def setup_agent(self) -> None:
|
||||
"""Set the next execution timestep when the episode resets."""
|
||||
self._select_start_node()
|
||||
self._set_next_execution_timestep(self.agent_settings.start_settings.start_step)
|
||||
for n, act in self.action_manager.action_map.items():
|
||||
if not act[0] == "NODE_APPLICATION_INSTALL":
|
||||
continue
|
||||
if act[1]["node_id"] == self.starting_node_idx:
|
||||
self.ip_address = act[1]["ip_address"]
|
||||
return
|
||||
raise RuntimeError("TAP001 agent could not find database server ip address in action map")
|
||||
|
||||
def _select_start_node(self) -> None:
|
||||
"""Set the starting starting node of the agent to be a random node from this agent's action manager."""
|
||||
# we are assuming that every node in the node manager has a data manipulation application at idx 0
|
||||
num_nodes = len(self.action_manager.node_names)
|
||||
self.starting_node_idx = random.randint(0, num_nodes - 1)
|
||||
@@ -7,14 +7,8 @@ import numpy as np
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from primaite import DEFAULT_BANDWIDTH, getLogger
|
||||
from primaite.game.agent.actions import ActionManager
|
||||
from primaite.game.agent.interface import AbstractAgent, AgentSettings, ProxyAgent
|
||||
from primaite.game.agent.observations.observation_manager import ObservationManager
|
||||
from primaite.game.agent.rewards import RewardFunction, SharedReward
|
||||
from primaite.game.agent.scripted_agents.data_manipulation_bot import DataManipulationAgent
|
||||
from primaite.game.agent.scripted_agents.probabilistic_agent import ProbabilisticAgent
|
||||
from primaite.game.agent.scripted_agents.random_agent import PeriodicAgent
|
||||
from primaite.game.agent.scripted_agents.tap001 import TAP001
|
||||
from primaite.game.agent.interface import AbstractAgent, ProxyAgent
|
||||
from primaite.game.agent.rewards import SharedReward
|
||||
from primaite.game.science import graph_has_cycle, topological_sort
|
||||
from primaite.simulator import SIM_OUTPUT
|
||||
from primaite.simulator.network.creation import NetworkNodeAdder
|
||||
@@ -44,7 +38,7 @@ from primaite.simulator.system.services.service import Service
|
||||
from primaite.simulator.system.services.terminal.terminal import Terminal
|
||||
from primaite.simulator.system.services.web_server.web_server import WebServer
|
||||
from primaite.simulator.system.software import Software
|
||||
from primaite.utils.validation.ip_protocol import IPProtocol, PROTOCOL_LOOKUP
|
||||
from primaite.utils.validation.ip_protocol import IPProtocol
|
||||
from primaite.utils.validation.port import Port, PORT_LOOKUP
|
||||
|
||||
_LOGGER = getLogger(__name__)
|
||||
@@ -258,6 +252,7 @@ class PrimaiteGame:
|
||||
net = sim.network
|
||||
|
||||
simulation_config = cfg.get("simulation", {})
|
||||
defaults_config = cfg.get("defaults", {})
|
||||
network_config = simulation_config.get("network", {})
|
||||
airspace_cfg = network_config.get("airspace", {})
|
||||
frequency_max_capacity_mbps_cfg = airspace_cfg.get("frequency_max_capacity_mbps", {})
|
||||
@@ -283,6 +278,18 @@ class PrimaiteGame:
|
||||
_LOGGER.error(msg)
|
||||
raise ValueError(msg)
|
||||
|
||||
# TODO: handle simulation defaults more cleanly
|
||||
if "node_start_up_duration" in defaults_config:
|
||||
new_node.start_up_duration = defaults_config["node_startup_duration"]
|
||||
if "node_shut_down_duration" in defaults_config:
|
||||
new_node.shut_down_duration = defaults_config["node_shut_down_duration"]
|
||||
if "node_scan_duration" in defaults_config:
|
||||
new_node.node_scan_duration = defaults_config["node_scan_duration"]
|
||||
if "folder_scan_duration" in defaults_config:
|
||||
new_node.file_system._default_folder_scan_duration = defaults_config["folder_scan_duration"]
|
||||
if "folder_restore_duration" in defaults_config:
|
||||
new_node.file_system._default_folder_restore_duration = defaults_config["folder_restore_duration"]
|
||||
|
||||
if "users" in node_cfg and new_node.software_manager.software.get("UserManager"):
|
||||
user_manager: UserManager = new_node.software_manager.software["UserManager"] # noqa
|
||||
for user_cfg in node_cfg["users"]:
|
||||
@@ -315,12 +322,12 @@ class PrimaiteGame:
|
||||
|
||||
if service_class is not None:
|
||||
_LOGGER.debug(f"installing {service_type} on node {new_node.config.hostname}")
|
||||
new_node.software_manager.install(service_class, **service_cfg.get("options", {}))
|
||||
new_node.software_manager.install(service_class)
|
||||
new_service = new_node.software_manager.software[service_class.__name__]
|
||||
|
||||
# fixing duration for the service
|
||||
if "fix_duration" in service_cfg.get("options", {}):
|
||||
new_service.fixing_duration = service_cfg["options"]["fix_duration"]
|
||||
if "fixing_duration" in service_cfg.get("options", {}):
|
||||
new_service.config.fixing_duration = service_cfg["options"]["fixing_duration"]
|
||||
|
||||
_set_software_listen_on_ports(new_service, service_cfg)
|
||||
# start the service
|
||||
@@ -329,6 +336,15 @@ class PrimaiteGame:
|
||||
msg = f"Configuration contains an invalid service type: {service_type}"
|
||||
_LOGGER.error(msg)
|
||||
raise ValueError(msg)
|
||||
|
||||
# TODO: handle simulation defaults more cleanly
|
||||
if "service_fix_duration" in defaults_config:
|
||||
new_service.fixing_duration = defaults_config["service_fix_duration"]
|
||||
if "service_restart_duration" in defaults_config:
|
||||
new_service.restart_duration = defaults_config["service_restart_duration"]
|
||||
if "service_install_duration" in defaults_config:
|
||||
new_service.install_duration = defaults_config["service_install_duration"]
|
||||
|
||||
# service-dependent options
|
||||
if service_type == "DNSClient":
|
||||
if "options" in service_cfg:
|
||||
@@ -361,74 +377,20 @@ class PrimaiteGame:
|
||||
application_type = application_cfg["type"]
|
||||
|
||||
if application_type in Application._registry:
|
||||
new_node.software_manager.install(Application._registry[application_type])
|
||||
application_class = Application._registry[application_type]
|
||||
application_options = application_cfg.get("options", {})
|
||||
application_options["type"] = application_type
|
||||
new_node.software_manager.install(application_class, software_config=application_options)
|
||||
new_application = new_node.software_manager.software[application_type] # grab the instance
|
||||
|
||||
# fixing duration for the application
|
||||
if "fix_duration" in application_cfg.get("options", {}):
|
||||
new_application.fixing_duration = application_cfg["options"]["fix_duration"]
|
||||
else:
|
||||
msg = f"Configuration contains an invalid application type: {application_type}"
|
||||
_LOGGER.error(msg)
|
||||
raise ValueError(msg)
|
||||
|
||||
_set_software_listen_on_ports(new_application, application_cfg)
|
||||
|
||||
# run the application
|
||||
new_application.run()
|
||||
|
||||
if application_type == "DataManipulationBot":
|
||||
if "options" in application_cfg:
|
||||
opt = application_cfg["options"]
|
||||
new_application.configure(
|
||||
server_ip_address=IPv4Address(opt.get("server_ip")),
|
||||
server_password=opt.get("server_password"),
|
||||
payload=opt.get("payload", "DELETE"),
|
||||
port_scan_p_of_success=float(opt.get("port_scan_p_of_success", "0.1")),
|
||||
data_manipulation_p_of_success=float(opt.get("data_manipulation_p_of_success", "0.1")),
|
||||
)
|
||||
elif application_type == "RansomwareScript":
|
||||
if "options" in application_cfg:
|
||||
opt = application_cfg["options"]
|
||||
new_application.configure(
|
||||
server_ip_address=IPv4Address(opt.get("server_ip")) if opt.get("server_ip") else None,
|
||||
server_password=opt.get("server_password"),
|
||||
payload=opt.get("payload", "ENCRYPT"),
|
||||
)
|
||||
elif application_type == "DatabaseClient":
|
||||
if "options" in application_cfg:
|
||||
opt = application_cfg["options"]
|
||||
new_application.configure(
|
||||
server_ip_address=IPv4Address(opt.get("db_server_ip")),
|
||||
server_password=opt.get("server_password"),
|
||||
)
|
||||
elif application_type == "WebBrowser":
|
||||
if "options" in application_cfg:
|
||||
opt = application_cfg["options"]
|
||||
new_application.target_url = opt.get("target_url")
|
||||
elif application_type == "DoSBot":
|
||||
if "options" in application_cfg:
|
||||
opt = application_cfg["options"]
|
||||
new_application.configure(
|
||||
target_ip_address=IPv4Address(opt.get("target_ip_address")),
|
||||
target_port=PORT_LOOKUP[opt.get("target_port", "POSTGRES_SERVER")],
|
||||
payload=opt.get("payload"),
|
||||
repeat=bool(opt.get("repeat")),
|
||||
port_scan_p_of_success=float(opt.get("port_scan_p_of_success", "0.1")),
|
||||
dos_intensity=float(opt.get("dos_intensity", "1.0")),
|
||||
max_sessions=int(opt.get("max_sessions", "1000")),
|
||||
)
|
||||
elif application_type == "C2Beacon":
|
||||
if "options" in application_cfg:
|
||||
opt = application_cfg["options"]
|
||||
new_application.configure(
|
||||
c2_server_ip_address=IPv4Address(opt.get("c2_server_ip_address")),
|
||||
keep_alive_frequency=(opt.get("keep_alive_frequency", 5)),
|
||||
masquerade_protocol=PROTOCOL_LOOKUP[
|
||||
(opt.get("masquerade_protocol", PROTOCOL_LOOKUP["TCP"]))
|
||||
],
|
||||
masquerade_port=PORT_LOOKUP[(opt.get("masquerade_port", PORT_LOOKUP["HTTP"]))],
|
||||
)
|
||||
if "network_interfaces" in node_cfg:
|
||||
for nic_num, nic_cfg in node_cfg["network_interfaces"].items():
|
||||
new_node.connect_nic(NIC(ip_address=nic_cfg["ip_address"], subnet_mask=nic_cfg["subnet_mask"]))
|
||||
@@ -470,76 +432,10 @@ class PrimaiteGame:
|
||||
agents_cfg = cfg.get("agents", [])
|
||||
|
||||
for agent_cfg in agents_cfg:
|
||||
agent_ref = agent_cfg["ref"] # noqa: F841
|
||||
agent_type = agent_cfg["type"]
|
||||
action_space_cfg = agent_cfg["action_space"]
|
||||
observation_space_cfg = agent_cfg["observation_space"]
|
||||
reward_function_cfg = agent_cfg["reward_function"]
|
||||
|
||||
# CREATE OBSERVATION SPACE
|
||||
obs_space = ObservationManager.from_config(observation_space_cfg)
|
||||
|
||||
# CREATE ACTION SPACE
|
||||
action_space = ActionManager.from_config(game, action_space_cfg)
|
||||
|
||||
# CREATE REWARD FUNCTION
|
||||
reward_function = RewardFunction.from_config(reward_function_cfg)
|
||||
|
||||
# CREATE AGENT
|
||||
if agent_type == "ProbabilisticAgent":
|
||||
# TODO: implement non-random agents and fix this parsing
|
||||
settings = agent_cfg.get("agent_settings", {})
|
||||
new_agent = ProbabilisticAgent(
|
||||
agent_name=agent_cfg["ref"],
|
||||
action_space=action_space,
|
||||
observation_space=obs_space,
|
||||
reward_function=reward_function,
|
||||
settings=settings,
|
||||
)
|
||||
elif agent_type == "PeriodicAgent":
|
||||
settings = PeriodicAgent.Settings(**agent_cfg.get("settings", {}))
|
||||
new_agent = PeriodicAgent(
|
||||
agent_name=agent_cfg["ref"],
|
||||
action_space=action_space,
|
||||
observation_space=obs_space,
|
||||
reward_function=reward_function,
|
||||
settings=settings,
|
||||
)
|
||||
|
||||
elif agent_type == "ProxyAgent":
|
||||
agent_settings = AgentSettings.from_config(agent_cfg.get("agent_settings"))
|
||||
new_agent = ProxyAgent(
|
||||
agent_name=agent_cfg["ref"],
|
||||
action_space=action_space,
|
||||
observation_space=obs_space,
|
||||
reward_function=reward_function,
|
||||
agent_settings=agent_settings,
|
||||
)
|
||||
game.rl_agents[agent_cfg["ref"]] = new_agent
|
||||
elif agent_type == "RedDatabaseCorruptingAgent":
|
||||
agent_settings = AgentSettings.from_config(agent_cfg.get("agent_settings"))
|
||||
|
||||
new_agent = DataManipulationAgent(
|
||||
agent_name=agent_cfg["ref"],
|
||||
action_space=action_space,
|
||||
observation_space=obs_space,
|
||||
reward_function=reward_function,
|
||||
agent_settings=agent_settings,
|
||||
)
|
||||
elif agent_type == "TAP001":
|
||||
agent_settings = AgentSettings.from_config(agent_cfg.get("agent_settings"))
|
||||
new_agent = TAP001(
|
||||
agent_name=agent_cfg["ref"],
|
||||
action_space=action_space,
|
||||
observation_space=obs_space,
|
||||
reward_function=reward_function,
|
||||
agent_settings=agent_settings,
|
||||
)
|
||||
else:
|
||||
msg = f"Configuration error: {agent_type} is not a valid agent type."
|
||||
_LOGGER.error(msg)
|
||||
raise ValueError(msg)
|
||||
new_agent = AbstractAgent.from_config(agent_cfg)
|
||||
game.agents[agent_cfg["ref"]] = new_agent
|
||||
if isinstance(new_agent, ProxyAgent):
|
||||
game.rl_agents[agent_cfg["ref"]] = new_agent
|
||||
|
||||
# Validate that if any agents are sharing rewards, they aren't forming an infinite loop.
|
||||
game.setup_reward_sharing()
|
||||
|
||||
Reference in New Issue
Block a user