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.
- hovel_sdk-0.1.0/PKG-INFO +5 -0
- hovel_sdk-0.1.0/README.md +95 -0
- hovel_sdk-0.1.0/hovel_sdk/__init__.py +29 -0
- hovel_sdk-0.1.0/hovel_sdk/config.py +26 -0
- hovel_sdk-0.1.0/hovel_sdk/context.py +100 -0
- hovel_sdk-0.1.0/hovel_sdk/framing.py +95 -0
- hovel_sdk-0.1.0/hovel_sdk/logging.py +42 -0
- hovel_sdk-0.1.0/hovel_sdk/module.py +54 -0
- hovel_sdk-0.1.0/hovel_sdk/result.py +226 -0
- hovel_sdk-0.1.0/hovel_sdk/sdk_test.py +593 -0
- hovel_sdk-0.1.0/hovel_sdk/server.py +142 -0
- hovel_sdk-0.1.0/hovel_sdk/session.py +286 -0
- hovel_sdk-0.1.0/hovel_sdk/testing.py +128 -0
- hovel_sdk-0.1.0/hovel_sdk.egg-info/PKG-INFO +5 -0
- hovel_sdk-0.1.0/hovel_sdk.egg-info/SOURCES.txt +17 -0
- hovel_sdk-0.1.0/hovel_sdk.egg-info/dependency_links.txt +1 -0
- hovel_sdk-0.1.0/hovel_sdk.egg-info/top_level.txt +1 -0
- hovel_sdk-0.1.0/pyproject.toml +66 -0
- hovel_sdk-0.1.0/setup.cfg +4 -0
hovel_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -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
|