hovel-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: hovel-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Hovel JSON-RPC modules
5
+ Requires-Python: >=3.12
@@ -0,0 +1,95 @@
1
+ # Python SDK
2
+
3
+ The Python SDK is the quickest path for exploit, survey, and post-exploitation
4
+ module work. Copy one of the examples in [`../../examples/python`](../../examples/python)
5
+ and keep the public boundary small: subclass `HovelModule`, describe cheap
6
+ metadata/configuration, then put target interaction in `run` or explicit step
7
+ hooks.
8
+
9
+ ## Module Shape
10
+
11
+ ```python
12
+ from hovel_sdk import Context, HovelModule, Requirement, Result, serve
13
+
14
+
15
+ class MyModule(HovelModule):
16
+ name = "my-module"
17
+ version = "v0.1.0"
18
+ module_type = "exploit"
19
+ summary = "Run a scoped module action."
20
+ target_config = (
21
+ Requirement("target.host", "host", description="Target host or IP."),
22
+ )
23
+
24
+ def run(self, ctx: Context) -> Result:
25
+ ctx.log.info("module started", extra={"target": ctx.target})
26
+ return Result.ok({"target": ctx.target}, summary="module completed")
27
+
28
+
29
+ if __name__ == "__main__":
30
+ serve(MyModule())
31
+ ```
32
+
33
+ Rules that matter in real integrations:
34
+
35
+ - Never print to stdout. The SDK uses stdout for framed JSON-RPC responses and
36
+ notifications.
37
+ - Use `ctx.log` for progress and diagnostics; Hovel turns those into
38
+ `module/log` notifications.
39
+ - Keep `info()` and `module_schema()` side-effect free. Hovel calls them while
40
+ cataloging modules.
41
+ - Use `Result.ok` or `Result.failed`; attach `Finding`, `Artifact`,
42
+ `SessionRef`, and `InstalledPayload` values deliberately.
43
+
44
+ ## Extension Points
45
+
46
+ | Need | Use |
47
+ | --- | --- |
48
+ | Regular module execution | `run(ctx) -> Result` or an awaitable `Result`. |
49
+ | Config requirements | `Requirement` in `global_config` and `target_config`. |
50
+ | Line-oriented post-exploitation sessions | `LineShellSession` opened with `await ctx.open_session(...)`. |
51
+ | Durable installed payload inventory | `InstalledPayload` and `PayloadProviderRecord` in a result. |
52
+ | Typed chain steps | Override `describe_steps`, `prepare_step`, `execute_step`, and `cleanup_step`. |
53
+
54
+ Python does not currently dispatch payload-provider RPC methods such as
55
+ `list_payloads` or `generate_payload`. Use Go for provider modules today, or
56
+ return installed-payload descriptors from a Python exploit when it installs or
57
+ observes a durable payload.
58
+
59
+ ## Test Loop
60
+
61
+ Use `ModuleRPC` to test through the same framed protocol the daemon uses. This
62
+ catches broken method names, result shapes, log notifications, and session
63
+ round trips without starting `hoveld`.
64
+
65
+ ```python
66
+ from hovel_sdk import ModuleRPC
67
+
68
+
69
+ def test_module_executes():
70
+ with ModuleRPC(MyModule()) as rpc:
71
+ result = rpc.call("execute", {"runId": "run-1", "target": "lab-1"})
72
+
73
+ assert result["status"] == "succeeded"
74
+ ```
75
+
76
+ Focused checks:
77
+
78
+ ```sh
79
+ task test -- //sdk/python:hovel_sdk_test
80
+ task test -- //examples/python/...
81
+ task python:check
82
+ ```
83
+
84
+ Full gate:
85
+
86
+ ```sh
87
+ task ci
88
+ ```
89
+
90
+ For deeper examples, compare:
91
+
92
+ - [`../../examples/python/mock_survey`](../../examples/python/mock_survey)
93
+ - [`../../examples/python/mock_exploit`](../../examples/python/mock_exploit)
94
+ - [`../../examples/python/mock_exploit_session`](../../examples/python/mock_exploit_session)
95
+ - [`../../examples/python/etro_exploit`](../../examples/python/etro_exploit)
@@ -0,0 +1,29 @@
1
+ from hovel_sdk.config import Requirement
2
+ from hovel_sdk.context import AgentContext, AgentEntity, Context
3
+ from hovel_sdk.logging import setup_logging
4
+ from hovel_sdk.module import HovelModule
5
+ from hovel_sdk.result import AgentHint, Artifact, Finding, InstalledPayload, PayloadProviderRecord, Result
6
+ from hovel_sdk.server import serve
7
+ from hovel_sdk.session import LineShellSession, SessionRef
8
+ from hovel_sdk.testing import ModuleRPC, RPCError, drive_module
9
+
10
+ __all__ = [
11
+ "AgentContext",
12
+ "AgentEntity",
13
+ "AgentHint",
14
+ "Artifact",
15
+ "Context",
16
+ "Finding",
17
+ "HovelModule",
18
+ "InstalledPayload",
19
+ "LineShellSession",
20
+ "ModuleRPC",
21
+ "PayloadProviderRecord",
22
+ "RPCError",
23
+ "Requirement",
24
+ "Result",
25
+ "SessionRef",
26
+ "drive_module",
27
+ "serve",
28
+ "setup_logging",
29
+ ]
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Requirement:
9
+ key: str
10
+ type: str = "string"
11
+ required: bool = True
12
+ default: str = ""
13
+ description: str = ""
14
+ allowed: list[str] = field(default_factory=list)
15
+ secret: bool = False
16
+
17
+ def to_rpc(self) -> dict[str, Any]:
18
+ return {
19
+ "key": self.key,
20
+ "type": self.type,
21
+ "required": self.required,
22
+ "default": self.default,
23
+ "description": self.description,
24
+ "allowed": list(self.allowed),
25
+ "secret": self.secret,
26
+ }
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from hovel_sdk.session import Session, SessionRef
8
+
9
+ if TYPE_CHECKING:
10
+ from hovel_sdk.session import SessionRegistry
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AgentEntity:
15
+ id: str = ""
16
+ kind: str = ""
17
+ display_name: str = ""
18
+ agent: bool = False
19
+
20
+ @classmethod
21
+ def from_rpc(cls, value: Any) -> AgentEntity:
22
+ if not isinstance(value, dict):
23
+ return cls()
24
+ return cls(
25
+ id=str(value.get("id", "")),
26
+ kind=str(value.get("kind", "")),
27
+ display_name=str(value.get("displayName", "")),
28
+ agent=bool(value.get("agent", False)),
29
+ )
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AgentContext:
34
+ schema: str = ""
35
+ entity: AgentEntity = field(default_factory=AgentEntity)
36
+ operation: str = ""
37
+ chain: str = ""
38
+ plan_id: str = ""
39
+ plan_hash: str = ""
40
+ approval_state: str = ""
41
+ phase: str = ""
42
+ resources: tuple[str, ...] = ()
43
+
44
+ @classmethod
45
+ def from_rpc(cls, value: Any) -> AgentContext | None:
46
+ if not isinstance(value, dict):
47
+ return None
48
+ resources = value.get("resources") or ()
49
+ if not isinstance(resources, (list, tuple)):
50
+ resources = ()
51
+ return cls(
52
+ schema=str(value.get("schema", "")),
53
+ entity=AgentEntity.from_rpc(value.get("entity")),
54
+ operation=str(value.get("operation", "")),
55
+ chain=str(value.get("chain", "")),
56
+ plan_id=str(value.get("planId", "")),
57
+ plan_hash=str(value.get("planHash", "")),
58
+ approval_state=str(value.get("approvalState", "")),
59
+ phase=str(value.get("phase", "")),
60
+ resources=tuple(str(item) for item in resources),
61
+ )
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Context:
66
+ run_id: str
67
+ module_id: str
68
+ target: str
69
+ inputs: dict[str, Any] = field(default_factory=dict)
70
+ chain_config: dict[str, Any] = field(default_factory=dict)
71
+ target_config: dict[str, Any] = field(default_factory=dict)
72
+ agent: AgentContext | None = None
73
+ log: logging.Logger = field(default_factory=lambda: logging.getLogger("hovel.module"))
74
+ sessions: SessionRegistry | None = field(default=None, repr=False)
75
+
76
+ def input(self, key: str, default: Any = None) -> Any:
77
+ if key in self.inputs:
78
+ return self.inputs[key]
79
+ if key in self.target_config:
80
+ return self.target_config[key]
81
+ return self.chain_config.get(key, default)
82
+
83
+ async def open_session(
84
+ self,
85
+ session: Session,
86
+ *,
87
+ name: str = "",
88
+ kind: str = "shell",
89
+ transport: str = "stdio",
90
+ capabilities: tuple[str, ...] = ("read", "write", "close"),
91
+ ) -> SessionRef:
92
+ if self.sessions is None:
93
+ raise RuntimeError("session support is not available in this runtime")
94
+ return await self.sessions.open(
95
+ session,
96
+ name=name,
97
+ kind=kind,
98
+ transport=transport,
99
+ capabilities=capabilities,
100
+ )
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ from typing import Any, BinaryIO
6
+
7
+ MAX_FRAME_BYTES = 64 * 1024 * 1024
8
+
9
+
10
+ class FrameError(Exception):
11
+ pass
12
+
13
+
14
+ def encode_message(message: dict[str, Any]) -> bytes:
15
+ body = json.dumps(message, separators=(",", ":"), sort_keys=True).encode("utf-8")
16
+ return b"Content-Length: " + str(len(body)).encode("ascii") + b"\r\n\r\n" + body
17
+
18
+
19
+ def read_message(stream: BinaryIO) -> dict[str, Any] | None:
20
+ header = _read_until(stream, b"\r\n\r\n")
21
+ if header == b"":
22
+ return None
23
+ content_length = _content_length_from_header(header)
24
+ body = stream.read(content_length)
25
+ if len(body) != content_length:
26
+ raise FrameError("truncated frame body")
27
+ return _decode_message_body(body)
28
+
29
+
30
+ def _content_length_from_header(header: bytes) -> int:
31
+ content_length: int | None = None
32
+ for line in header.decode("ascii").split("\r\n"):
33
+ if not line:
34
+ continue
35
+ name, sep, value = line.partition(":")
36
+ if sep == "" or name.lower() != "content-length":
37
+ continue
38
+ try:
39
+ content_length = int(value.strip())
40
+ except ValueError as exc:
41
+ raise FrameError("invalid Content-Length") from exc
42
+ if content_length is None:
43
+ raise FrameError("missing Content-Length")
44
+ if content_length < 0:
45
+ raise FrameError("invalid Content-Length")
46
+ if content_length > MAX_FRAME_BYTES:
47
+ raise FrameError(f"Content-Length {content_length} exceeds maximum {MAX_FRAME_BYTES}")
48
+ return content_length
49
+
50
+
51
+ def _decode_message_body(body: bytes) -> dict[str, Any]:
52
+ try:
53
+ decoded = json.loads(body.decode("utf-8"))
54
+ except json.JSONDecodeError as exc:
55
+ raise FrameError("invalid JSON frame body") from exc
56
+ if not isinstance(decoded, dict):
57
+ raise FrameError("JSON-RPC message must be an object")
58
+ return decoded
59
+
60
+
61
+ def write_message(stream: BinaryIO, message: dict[str, Any]) -> None:
62
+ stream.write(encode_message(message))
63
+ stream.flush()
64
+
65
+
66
+ class MessageWriter:
67
+ """Serialize framed writes to one stream.
68
+
69
+ A module may emit logs or session notifications from code paths that overlap
70
+ request handling. The protocol is stdout-framed, so every frame write must
71
+ be atomic with respect to other frame writes.
72
+ """
73
+
74
+ def __init__(self, stream: BinaryIO) -> None:
75
+ self._stream = stream
76
+ self._lock = threading.Lock()
77
+
78
+ def write(self, message: dict[str, Any]) -> None:
79
+ encoded = encode_message(message)
80
+ with self._lock:
81
+ self._stream.write(encoded)
82
+ self._stream.flush()
83
+
84
+
85
+ def _read_until(stream: BinaryIO, marker: bytes) -> bytes:
86
+ data = bytearray()
87
+ while True:
88
+ chunk = stream.read(1)
89
+ if chunk == b"":
90
+ if not data:
91
+ return b""
92
+ raise FrameError("truncated frame header")
93
+ data.extend(chunk)
94
+ if data.endswith(marker):
95
+ return bytes(data[: -len(marker)])
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import traceback
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ _RESERVED = set(logging.LogRecord("", 0, "", 0, "", (), None).__dict__.keys())
9
+
10
+
11
+ class RPCLogHandler(logging.Handler):
12
+ def __init__(self, emit: Callable[[dict[str, Any]], None]) -> None:
13
+ super().__init__()
14
+ self._emit = emit
15
+
16
+ def emit(self, record: logging.LogRecord) -> None:
17
+ fields: dict[str, Any] = {}
18
+ for key, value in record.__dict__.items():
19
+ if key.startswith("_") or key in _RESERVED:
20
+ continue
21
+ fields[key] = value
22
+ params: dict[str, Any] = {
23
+ "level": record.levelname.lower(),
24
+ "message": record.getMessage(),
25
+ "logger": record.name,
26
+ "fields": fields,
27
+ }
28
+ if record.exc_info:
29
+ params["exception"] = "".join(traceback.format_exception(*record.exc_info))
30
+ self._emit(params)
31
+
32
+
33
+ def setup_logging(emit: Callable[[dict[str, Any]], None] | None = None) -> RPCLogHandler:
34
+ if emit is None:
35
+ def emit(_params: dict[str, Any]) -> None:
36
+ return
37
+
38
+ handler = RPCLogHandler(emit)
39
+ root = logging.getLogger()
40
+ root.addHandler(handler)
41
+ root.setLevel(logging.INFO)
42
+ return handler
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Awaitable
5
+ from typing import Any, ClassVar
6
+
7
+ from hovel_sdk.config import Requirement
8
+ from hovel_sdk.context import Context
9
+ from hovel_sdk.result import Result
10
+
11
+
12
+ class HovelModule(ABC):
13
+ name: str = ""
14
+ version: str = "0.0.0"
15
+ summary: str = ""
16
+ module_type: str = ""
17
+ description: str = ""
18
+ tags: ClassVar[tuple[str, ...]] = ()
19
+ global_config: ClassVar[tuple[Requirement, ...]] = ()
20
+ target_config: ClassVar[tuple[Requirement, ...]] = ()
21
+ outputs: ClassVar[dict[str, Any]] = {}
22
+
23
+ def info(self) -> dict[str, Any]:
24
+ return {
25
+ "name": self.name,
26
+ "version": self.version,
27
+ "summary": self.summary,
28
+ "description": self.description,
29
+ "moduleType": self.module_type,
30
+ "tags": list(self.tags),
31
+ }
32
+
33
+ def module_schema(self) -> dict[str, Any]:
34
+ return {
35
+ "chainConfig": [requirement.to_rpc() for requirement in self.global_config],
36
+ "targetConfig": [requirement.to_rpc() for requirement in self.target_config],
37
+ "outputs": dict(self.outputs),
38
+ }
39
+
40
+ def describe_steps(self) -> dict[str, Any]:
41
+ return {"steps": []}
42
+
43
+ def prepare_step(self, request: dict[str, Any]) -> dict[str, Any]:
44
+ raise NotImplementedError(f"{self.name or self.__class__.__name__} does not implement step.prepare")
45
+
46
+ def execute_step(self, request: dict[str, Any]) -> dict[str, Any]:
47
+ raise NotImplementedError(f"{self.name or self.__class__.__name__} does not implement step.execute")
48
+
49
+ def cleanup_step(self, request: dict[str, Any]) -> dict[str, Any]:
50
+ raise NotImplementedError(f"{self.name or self.__class__.__name__} does not implement step.cleanup")
51
+
52
+ @abstractmethod
53
+ def run(self, ctx: Context) -> Result | Awaitable[Result]:
54
+ raise NotImplementedError
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field, replace
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from hovel_sdk.session import SessionRef
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class AgentHint:
13
+ phase: str
14
+ audience: str
15
+ risk: str
16
+ text: str
17
+ schema: str = "hovel.agent_hint.v1"
18
+ applies_to: dict[str, str] = field(default_factory=dict)
19
+ provenance: dict[str, str] = field(default_factory=dict)
20
+
21
+ def to_rpc(self) -> dict[str, Any]:
22
+ out: dict[str, Any] = {
23
+ "schema": self.schema,
24
+ "phase": self.phase,
25
+ "audience": self.audience,
26
+ "risk": self.risk,
27
+ "text": self.text,
28
+ }
29
+ if self.applies_to:
30
+ out["appliesTo"] = dict(self.applies_to)
31
+ if self.provenance:
32
+ out["provenance"] = dict(self.provenance)
33
+ return out
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Finding:
38
+ title: str
39
+ severity: str = "info"
40
+ detail: str = ""
41
+
42
+ def to_rpc(self) -> dict[str, Any]:
43
+ return {"title": self.title, "severity": self.severity, "detail": self.detail}
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Artifact:
48
+ name: str
49
+ kind: str
50
+ data: str = ""
51
+ path: str = ""
52
+
53
+ @classmethod
54
+ def inline(cls, name: str, kind: str, data: str | bytes) -> Artifact:
55
+ if isinstance(data, bytes):
56
+ data = data.decode("utf-8", errors="replace")
57
+ return cls(name=name, kind=kind, data=data)
58
+
59
+ @classmethod
60
+ def text(cls, name: str, data: str | bytes) -> Artifact:
61
+ return cls.inline(name, "text/plain", data)
62
+
63
+ @classmethod
64
+ def json(cls, name: str, data: Any) -> Artifact:
65
+ return cls.inline(name, "application/json", json.dumps(data, sort_keys=True, separators=(",", ":")))
66
+
67
+ @classmethod
68
+ def file(cls, path: str | Path, *, name: str | None = None, kind: str = "application/octet-stream") -> Artifact:
69
+ path = Path(path)
70
+ return cls(name=name or path.name, kind=kind, path=str(path))
71
+
72
+ def to_rpc(self) -> dict[str, Any]:
73
+ out = {"name": self.name, "kind": self.kind}
74
+ if self.data:
75
+ out["data"] = self.data
76
+ if self.path:
77
+ out["path"] = self.path
78
+ return out
79
+
80
+
81
+ @dataclass(frozen=True)
82
+ class PayloadProviderRecord:
83
+ schema: str = ""
84
+ descriptor: dict[str, Any] = field(default_factory=dict)
85
+ provider_id: str = ""
86
+ schema_version: str = ""
87
+
88
+ def to_rpc(self) -> dict[str, Any]:
89
+ out: dict[str, Any] = {}
90
+ if self.provider_id:
91
+ out["providerId"] = self.provider_id
92
+ if self.schema:
93
+ out["schema"] = self.schema
94
+ if self.schema_version:
95
+ out["schemaVersion"] = self.schema_version
96
+ if self.descriptor:
97
+ out["descriptor"] = dict(self.descriptor)
98
+ return out
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class InstalledPayload:
103
+ provider: str
104
+ payload_id: str
105
+ target: str
106
+ state: str
107
+ payload_version: str = ""
108
+ target_id: str = ""
109
+ transport: str = ""
110
+ endpoint: str = ""
111
+ instance_key: str = ""
112
+ stamp_id: str = ""
113
+ supports_reconnect: bool = False
114
+ supports_multiple_sessions: bool = False
115
+ reconnect: PayloadProviderRecord | None = None
116
+ cleanup: PayloadProviderRecord | None = None
117
+ metadata: dict[str, Any] = field(default_factory=dict)
118
+
119
+ def to_rpc(self) -> dict[str, Any]:
120
+ out: dict[str, Any] = {
121
+ "provider": self.provider,
122
+ "payloadId": self.payload_id,
123
+ "target": self.target,
124
+ "state": self.state,
125
+ }
126
+ optional_strings = {
127
+ "payloadVersion": self.payload_version,
128
+ "targetId": self.target_id,
129
+ "transport": self.transport,
130
+ "endpoint": self.endpoint,
131
+ "instanceKey": self.instance_key,
132
+ "stampId": self.stamp_id,
133
+ }
134
+ out.update({key: value for key, value in optional_strings.items() if value})
135
+ if self.supports_reconnect:
136
+ out["supportsReconnect"] = True
137
+ if self.supports_multiple_sessions:
138
+ out["supportsMultipleSessions"] = True
139
+ if self.reconnect is not None:
140
+ out["reconnect"] = self.reconnect.to_rpc()
141
+ if self.cleanup is not None:
142
+ out["cleanup"] = self.cleanup.to_rpc()
143
+ if self.metadata:
144
+ out["metadata"] = dict(self.metadata)
145
+ return out
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class Result:
150
+ status: str
151
+ summary: str
152
+ findings: list[Finding] = field(default_factory=list)
153
+ artifacts: list[Artifact] = field(default_factory=list)
154
+ outputs: dict[str, Any] = field(default_factory=dict)
155
+ sessions: list[SessionRef] = field(default_factory=list)
156
+ installed_payloads: list[InstalledPayload | dict[str, Any]] = field(default_factory=list)
157
+ agent_hints: list[AgentHint | dict[str, Any]] = field(default_factory=list)
158
+
159
+ @classmethod
160
+ def ok(
161
+ cls,
162
+ outputs: dict[str, Any] | None = None,
163
+ *,
164
+ summary: str = "module completed",
165
+ findings: list[Finding] | None = None,
166
+ artifacts: list[Artifact] | None = None,
167
+ sessions: list[SessionRef] | None = None,
168
+ ) -> Result:
169
+ return cls(
170
+ status="succeeded",
171
+ summary=summary,
172
+ findings=findings or [],
173
+ artifacts=artifacts or [],
174
+ outputs=outputs or {},
175
+ sessions=sessions or [],
176
+ )
177
+
178
+ @classmethod
179
+ def failed(
180
+ cls,
181
+ summary: str,
182
+ *,
183
+ findings: list[Finding] | None = None,
184
+ artifacts: list[Artifact] | None = None,
185
+ outputs: dict[str, Any] | None = None,
186
+ sessions: list[SessionRef] | None = None,
187
+ ) -> Result:
188
+ return cls(
189
+ status="failed",
190
+ summary=summary,
191
+ findings=findings or [],
192
+ artifacts=artifacts or [],
193
+ outputs=outputs or {},
194
+ sessions=sessions or [],
195
+ )
196
+
197
+ def with_installed_payloads(self, *payloads: InstalledPayload | dict[str, Any]) -> Result:
198
+ return replace(self, installed_payloads=[*self.installed_payloads, *payloads])
199
+
200
+ def with_agent_hints(self, *hints: AgentHint | dict[str, Any]) -> Result:
201
+ return replace(self, agent_hints=[*self.agent_hints, *hints])
202
+
203
+ def to_rpc(self, *, sessions: list[SessionRef] | None = None) -> dict[str, Any]:
204
+ session_refs = list(self.sessions)
205
+ if sessions:
206
+ seen = {session.id for session in session_refs}
207
+ session_refs.extend(session for session in sessions if session.id not in seen)
208
+ out = {
209
+ "status": self.status,
210
+ "summary": self.summary,
211
+ "findings": [finding.to_rpc() for finding in self.findings],
212
+ "artifacts": [artifact.to_rpc() for artifact in self.artifacts],
213
+ "outputs": dict(self.outputs),
214
+ "sessions": [session.to_rpc() for session in session_refs],
215
+ }
216
+ if self.installed_payloads:
217
+ out["installedPayloads"] = [
218
+ payload.to_rpc() if hasattr(payload, "to_rpc") else dict(payload)
219
+ for payload in self.installed_payloads
220
+ ]
221
+ if self.agent_hints:
222
+ out["agentHints"] = [
223
+ hint.to_rpc() if hasattr(hint, "to_rpc") else dict(hint)
224
+ for hint in self.agent_hints
225
+ ]
226
+ return out