cyber-shell-wrapper 0.1.0__py3-none-any.whl
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.
- cyber_shell/__init__.py +5 -0
- cyber_shell/assembler.py +174 -0
- cyber_shell/cli.py +125 -0
- cyber_shell/config.py +219 -0
- cyber_shell/logging_utils.py +25 -0
- cyber_shell/mock_endpoint.py +218 -0
- cyber_shell/models.py +23 -0
- cyber_shell/rcfile.py +147 -0
- cyber_shell/shell_wrapper.py +292 -0
- cyber_shell/telemetry.py +121 -0
- cyber_shell_wrapper-0.1.0.dist-info/METADATA +196 -0
- cyber_shell_wrapper-0.1.0.dist-info/RECORD +15 -0
- cyber_shell_wrapper-0.1.0.dist-info/WHEEL +5 -0
- cyber_shell_wrapper-0.1.0.dist-info/entry_points.txt +2 -0
- cyber_shell_wrapper-0.1.0.dist-info/top_level.txt +1 -0
cyber_shell/__init__.py
ADDED
cyber_shell/assembler.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shlex
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .config import AppConfig
|
|
8
|
+
from .models import ShellEvent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
INTERACTIVE_COMMANDS = {
|
|
12
|
+
"alsamixer",
|
|
13
|
+
"ftp",
|
|
14
|
+
"htop",
|
|
15
|
+
"less",
|
|
16
|
+
"man",
|
|
17
|
+
"more",
|
|
18
|
+
"mongo",
|
|
19
|
+
"mysql",
|
|
20
|
+
"nano",
|
|
21
|
+
"nmtui",
|
|
22
|
+
"psql",
|
|
23
|
+
"python",
|
|
24
|
+
"python3",
|
|
25
|
+
"redis-cli",
|
|
26
|
+
"sftp",
|
|
27
|
+
"ssh",
|
|
28
|
+
"sqlite3",
|
|
29
|
+
"telnet",
|
|
30
|
+
"tig",
|
|
31
|
+
"tmux",
|
|
32
|
+
"top",
|
|
33
|
+
"vi",
|
|
34
|
+
"view",
|
|
35
|
+
"vim",
|
|
36
|
+
"watch",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
PREFIX_WRAPPERS = {
|
|
40
|
+
"builtin",
|
|
41
|
+
"chronic",
|
|
42
|
+
"command",
|
|
43
|
+
"env",
|
|
44
|
+
"exec",
|
|
45
|
+
"nohup",
|
|
46
|
+
"stdbuf",
|
|
47
|
+
"time",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
ANSI_ESCAPE_RE = re.compile(
|
|
51
|
+
r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x1b\x07]*(?:\x07|\x1b\\))"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(slots=True)
|
|
56
|
+
class ActiveCommand:
|
|
57
|
+
started_at: str
|
|
58
|
+
cmd: str
|
|
59
|
+
output_buffer: bytearray
|
|
60
|
+
truncated: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class EventAssembler:
|
|
64
|
+
def __init__(self, config: AppConfig, session_id: str) -> None:
|
|
65
|
+
self._config = config
|
|
66
|
+
self._session_id = session_id
|
|
67
|
+
self._seq = 0
|
|
68
|
+
self._current: ActiveCommand | None = None
|
|
69
|
+
|
|
70
|
+
def start_command(self, started_at: str, cmd: str) -> None:
|
|
71
|
+
self._current = ActiveCommand(
|
|
72
|
+
started_at=started_at,
|
|
73
|
+
cmd=cmd.strip(),
|
|
74
|
+
output_buffer=bytearray(),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def append_output(self, chunk: bytes) -> None:
|
|
78
|
+
if self._current is None or not chunk:
|
|
79
|
+
return
|
|
80
|
+
remaining = self._config.max_output_bytes - len(self._current.output_buffer)
|
|
81
|
+
if remaining <= 0:
|
|
82
|
+
self._current.truncated = True
|
|
83
|
+
return
|
|
84
|
+
self._current.output_buffer.extend(chunk[:remaining])
|
|
85
|
+
if len(chunk) > remaining:
|
|
86
|
+
self._current.truncated = True
|
|
87
|
+
|
|
88
|
+
def finish_command(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
finished_at: str,
|
|
92
|
+
exit_code: int,
|
|
93
|
+
cwd: str,
|
|
94
|
+
) -> ShellEvent | None:
|
|
95
|
+
current = self._current
|
|
96
|
+
self._current = None
|
|
97
|
+
if current is None or not current.cmd:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
self._seq += 1
|
|
101
|
+
return ShellEvent(
|
|
102
|
+
session_id=self._session_id,
|
|
103
|
+
hostname=self._config.hostname,
|
|
104
|
+
shell="bash",
|
|
105
|
+
seq=self._seq,
|
|
106
|
+
cwd=cwd,
|
|
107
|
+
cmd=current.cmd,
|
|
108
|
+
exit_code=exit_code,
|
|
109
|
+
output=_sanitize_output(
|
|
110
|
+
current.output_buffer.decode("utf-8", errors="replace")
|
|
111
|
+
),
|
|
112
|
+
output_truncated=current.truncated,
|
|
113
|
+
started_at=current.started_at,
|
|
114
|
+
finished_at=finished_at,
|
|
115
|
+
is_interactive=is_interactive_command(current.cmd),
|
|
116
|
+
metadata=dict(self._config.metadata),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_interactive_command(command: str) -> bool:
|
|
121
|
+
executable = _extract_command_name(command)
|
|
122
|
+
if executable is None:
|
|
123
|
+
return False
|
|
124
|
+
return executable in INTERACTIVE_COMMANDS
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_command_name(command: str) -> str | None:
|
|
128
|
+
try:
|
|
129
|
+
tokens = shlex.split(command, posix=True)
|
|
130
|
+
except ValueError:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
index = 0
|
|
134
|
+
while index < len(tokens):
|
|
135
|
+
token = tokens[index]
|
|
136
|
+
if _looks_like_env_assignment(token):
|
|
137
|
+
index += 1
|
|
138
|
+
continue
|
|
139
|
+
if token == "sudo":
|
|
140
|
+
index += 1
|
|
141
|
+
while index < len(tokens):
|
|
142
|
+
sudo_token = tokens[index]
|
|
143
|
+
if sudo_token == "--":
|
|
144
|
+
index += 1
|
|
145
|
+
break
|
|
146
|
+
if not sudo_token.startswith("-"):
|
|
147
|
+
break
|
|
148
|
+
index += 1
|
|
149
|
+
if sudo_token in {"-g", "-h", "-p", "-u"} and index < len(tokens):
|
|
150
|
+
index += 1
|
|
151
|
+
continue
|
|
152
|
+
if token == "env":
|
|
153
|
+
index += 1
|
|
154
|
+
while index < len(tokens) and _looks_like_env_assignment(tokens[index]):
|
|
155
|
+
index += 1
|
|
156
|
+
continue
|
|
157
|
+
if token in PREFIX_WRAPPERS:
|
|
158
|
+
index += 1
|
|
159
|
+
continue
|
|
160
|
+
return token
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _looks_like_env_assignment(token: str) -> bool:
|
|
165
|
+
if "=" not in token or token.startswith("="):
|
|
166
|
+
return False
|
|
167
|
+
name, _ = token.split("=", 1)
|
|
168
|
+
return name.replace("_", "A").isalnum()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _sanitize_output(value: str) -> str:
|
|
172
|
+
cleaned = ANSI_ESCAPE_RE.sub("", value)
|
|
173
|
+
cleaned = cleaned.replace("\r\n", "\n").replace("\r", "\n")
|
|
174
|
+
return cleaned
|
cyber_shell/cli.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .config import default_config_text, has_runtime_overrides, load_config, persist_config
|
|
7
|
+
from .logging_utils import configure_logging
|
|
8
|
+
from .mock_endpoint import run_mock_endpoint
|
|
9
|
+
from .telemetry import TelemetryClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="cyber-shell",
|
|
15
|
+
description="Local PTY shell wrapper for cyber range telemetry.",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--config",
|
|
19
|
+
help="Global config path shortcut for default start command.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--endpoint-url",
|
|
23
|
+
help="Override telemetry endpoint for the wrapped shell session.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--api-key",
|
|
27
|
+
help="Override telemetry API key for the wrapped shell session.",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
31
|
+
|
|
32
|
+
start_parser = subparsers.add_parser("start", help="Start wrapped Bash session.")
|
|
33
|
+
start_parser.add_argument(
|
|
34
|
+
"--config",
|
|
35
|
+
help="Path to config.yaml. Defaults to ~/.config/cyber-shell/config.yaml",
|
|
36
|
+
)
|
|
37
|
+
start_parser.add_argument(
|
|
38
|
+
"--endpoint-url",
|
|
39
|
+
help="Override telemetry endpoint for this session.",
|
|
40
|
+
)
|
|
41
|
+
start_parser.add_argument(
|
|
42
|
+
"--api-key",
|
|
43
|
+
help="Override telemetry API key for this session.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
mock_parser = subparsers.add_parser(
|
|
47
|
+
"mock-endpoint",
|
|
48
|
+
help="Run a local mock telemetry endpoint.",
|
|
49
|
+
)
|
|
50
|
+
mock_parser.add_argument("--host", default="127.0.0.1")
|
|
51
|
+
mock_parser.add_argument("--port", type=int, default=8080)
|
|
52
|
+
mock_parser.add_argument("--api-key")
|
|
53
|
+
|
|
54
|
+
subparsers.add_parser(
|
|
55
|
+
"print-default-config",
|
|
56
|
+
help="Print a default config.yaml template.",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: list[str] | None = None) -> int:
|
|
63
|
+
parser = build_parser()
|
|
64
|
+
args = parser.parse_args(argv)
|
|
65
|
+
command = args.command or "start"
|
|
66
|
+
|
|
67
|
+
if command == "print-default-config":
|
|
68
|
+
sys.stdout.write(default_config_text())
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
if command == "mock-endpoint":
|
|
72
|
+
return run_mock_endpoint(args.host, args.port, args.api_key)
|
|
73
|
+
|
|
74
|
+
config_path = getattr(args, "config", None)
|
|
75
|
+
config = load_config(config_path)
|
|
76
|
+
if getattr(args, "endpoint_url", None):
|
|
77
|
+
config.endpoint_url = args.endpoint_url
|
|
78
|
+
if getattr(args, "api_key", None):
|
|
79
|
+
config.api_key = args.api_key
|
|
80
|
+
logger = configure_logging(config.state_dir)
|
|
81
|
+
|
|
82
|
+
if command == "start":
|
|
83
|
+
if has_runtime_overrides(
|
|
84
|
+
{
|
|
85
|
+
"endpoint_url": getattr(args, "endpoint_url", None),
|
|
86
|
+
"api_key": getattr(args, "api_key", None),
|
|
87
|
+
}
|
|
88
|
+
):
|
|
89
|
+
persisted_path = persist_config(config)
|
|
90
|
+
print(
|
|
91
|
+
f"cyber-shell: updated config -> {persisted_path}",
|
|
92
|
+
file=sys.stderr,
|
|
93
|
+
)
|
|
94
|
+
if config.endpoint_url:
|
|
95
|
+
print(
|
|
96
|
+
f"cyber-shell: telemetry -> {config.endpoint_url}",
|
|
97
|
+
file=sys.stderr,
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
print(
|
|
101
|
+
"cyber-shell: telemetry disabled; set endpoint_url in config, "
|
|
102
|
+
"export CYBER_SHELL_ENDPOINT_URL, or pass --endpoint-url",
|
|
103
|
+
file=sys.stderr,
|
|
104
|
+
)
|
|
105
|
+
if config.endpoint_url and not config.api_key:
|
|
106
|
+
print(
|
|
107
|
+
"cyber-shell: telemetry has no API key; pass --api-key or "
|
|
108
|
+
"export CYBER_SHELL_API_KEY if the endpoint requires auth",
|
|
109
|
+
file=sys.stderr,
|
|
110
|
+
)
|
|
111
|
+
from .shell_wrapper import ShellWrapper
|
|
112
|
+
|
|
113
|
+
telemetry = TelemetryClient(config, logger)
|
|
114
|
+
try:
|
|
115
|
+
wrapper = ShellWrapper(config, telemetry, logger)
|
|
116
|
+
return wrapper.run()
|
|
117
|
+
finally:
|
|
118
|
+
telemetry.close()
|
|
119
|
+
|
|
120
|
+
parser.print_help()
|
|
121
|
+
return 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
if __name__ == "__main__":
|
|
125
|
+
raise SystemExit(main())
|
cyber_shell/config.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import socket
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "cyber-shell" / "config.yaml"
|
|
10
|
+
DEFAULT_STATE_DIR = Path.home() / ".local" / "state" / "cyber-shell"
|
|
11
|
+
PERSISTED_ENV_KEYS = {
|
|
12
|
+
"CYBER_SHELL_CONFIG",
|
|
13
|
+
"CYBER_SHELL_ENDPOINT_URL",
|
|
14
|
+
"CYBER_SHELL_API_KEY",
|
|
15
|
+
"CYBER_SHELL_TIMEOUT_MS",
|
|
16
|
+
"CYBER_SHELL_RETRY_MAX",
|
|
17
|
+
"CYBER_SHELL_RETRY_BACKOFF_MS",
|
|
18
|
+
"CYBER_SHELL_MAX_OUTPUT_BYTES",
|
|
19
|
+
"CYBER_SHELL_QUEUE_SIZE",
|
|
20
|
+
"CYBER_SHELL_SHELL_PATH",
|
|
21
|
+
"CYBER_SHELL_HOSTNAME",
|
|
22
|
+
"CYBER_SHELL_STATE_DIR",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class AppConfig:
|
|
28
|
+
endpoint_url: str | None = None
|
|
29
|
+
api_key: str | None = None
|
|
30
|
+
timeout_ms: int = 3000
|
|
31
|
+
retry_max: int = 3
|
|
32
|
+
retry_backoff_ms: int = 1000
|
|
33
|
+
max_output_bytes: int = 262144
|
|
34
|
+
queue_size: int = 256
|
|
35
|
+
shell_path: str = "/bin/bash"
|
|
36
|
+
state_dir: Path = DEFAULT_STATE_DIR
|
|
37
|
+
config_path: Path = DEFAULT_CONFIG_PATH
|
|
38
|
+
hostname: str = field(default_factory=socket.gethostname)
|
|
39
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
def ensure_state_dir(self) -> Path:
|
|
42
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return self.state_dir
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_config(path: str | Path | None = None) -> AppConfig:
|
|
47
|
+
config_path = Path(
|
|
48
|
+
path
|
|
49
|
+
or os.environ.get("CYBER_SHELL_CONFIG")
|
|
50
|
+
or DEFAULT_CONFIG_PATH
|
|
51
|
+
).expanduser()
|
|
52
|
+
data: dict[str, object] = {}
|
|
53
|
+
if config_path.exists():
|
|
54
|
+
data = _parse_simple_yaml(config_path.read_text(encoding="utf-8"))
|
|
55
|
+
|
|
56
|
+
config = AppConfig(
|
|
57
|
+
endpoint_url=_env_or_data("CYBER_SHELL_ENDPOINT_URL", data, "endpoint_url"),
|
|
58
|
+
api_key=_env_or_data("CYBER_SHELL_API_KEY", data, "api_key"),
|
|
59
|
+
timeout_ms=_coerce_int(
|
|
60
|
+
_env_or_data("CYBER_SHELL_TIMEOUT_MS", data, "timeout_ms"), 3000
|
|
61
|
+
),
|
|
62
|
+
retry_max=_coerce_int(
|
|
63
|
+
_env_or_data("CYBER_SHELL_RETRY_MAX", data, "retry_max"), 3
|
|
64
|
+
),
|
|
65
|
+
retry_backoff_ms=_coerce_int(
|
|
66
|
+
_env_or_data("CYBER_SHELL_RETRY_BACKOFF_MS", data, "retry_backoff_ms"),
|
|
67
|
+
1000,
|
|
68
|
+
),
|
|
69
|
+
max_output_bytes=_coerce_int(
|
|
70
|
+
_env_or_data("CYBER_SHELL_MAX_OUTPUT_BYTES", data, "max_output_bytes"),
|
|
71
|
+
262144,
|
|
72
|
+
),
|
|
73
|
+
queue_size=_coerce_int(
|
|
74
|
+
_env_or_data("CYBER_SHELL_QUEUE_SIZE", data, "queue_size"), 256
|
|
75
|
+
),
|
|
76
|
+
shell_path=str(
|
|
77
|
+
_env_or_data("CYBER_SHELL_SHELL_PATH", data, "shell_path") or "/bin/bash"
|
|
78
|
+
),
|
|
79
|
+
state_dir=Path(
|
|
80
|
+
_env_or_data("CYBER_SHELL_STATE_DIR", data, "state_dir")
|
|
81
|
+
or DEFAULT_STATE_DIR
|
|
82
|
+
).expanduser(),
|
|
83
|
+
config_path=config_path,
|
|
84
|
+
hostname=str(
|
|
85
|
+
_env_or_data("CYBER_SHELL_HOSTNAME", data, "hostname")
|
|
86
|
+
or socket.gethostname()
|
|
87
|
+
),
|
|
88
|
+
metadata=_coerce_metadata(data.get("metadata")),
|
|
89
|
+
)
|
|
90
|
+
config.ensure_state_dir()
|
|
91
|
+
return config
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def default_config_text() -> str:
|
|
95
|
+
return _serialize_config(
|
|
96
|
+
AppConfig(
|
|
97
|
+
endpoint_url="http://127.0.0.1:8080/api/terminal-events",
|
|
98
|
+
api_key="replace-me",
|
|
99
|
+
timeout_ms=3000,
|
|
100
|
+
retry_max=3,
|
|
101
|
+
retry_backoff_ms=1000,
|
|
102
|
+
max_output_bytes=262144,
|
|
103
|
+
queue_size=256,
|
|
104
|
+
shell_path="/bin/bash",
|
|
105
|
+
metadata={"hostname_group": "kali-lab"},
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def persist_config(config: AppConfig) -> Path:
|
|
111
|
+
config.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
config.config_path.write_text(_serialize_config(config), encoding="utf-8")
|
|
113
|
+
if os.name == "posix":
|
|
114
|
+
os.chmod(config.config_path, 0o600)
|
|
115
|
+
return config.config_path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def has_runtime_overrides(cli_args: dict[str, object] | None = None) -> bool:
|
|
119
|
+
if cli_args:
|
|
120
|
+
if cli_args.get("endpoint_url") or cli_args.get("api_key"):
|
|
121
|
+
return True
|
|
122
|
+
return any(key in os.environ for key in PERSISTED_ENV_KEYS)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _env_or_data(env_name: str, data: dict[str, object], key: str) -> object:
|
|
126
|
+
if env_name in os.environ:
|
|
127
|
+
return os.environ[env_name]
|
|
128
|
+
return data.get(key)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _coerce_int(value: object, default: int) -> int:
|
|
132
|
+
if value in (None, ""):
|
|
133
|
+
return default
|
|
134
|
+
try:
|
|
135
|
+
return int(str(value))
|
|
136
|
+
except ValueError:
|
|
137
|
+
return default
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _coerce_metadata(value: object) -> dict[str, str]:
|
|
141
|
+
if not isinstance(value, dict):
|
|
142
|
+
return {}
|
|
143
|
+
return {str(key): str(inner) for key, inner in value.items()}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _serialize_config(config: AppConfig) -> str:
|
|
147
|
+
lines = [
|
|
148
|
+
f'endpoint_url: {_yaml_string(config.endpoint_url)}'
|
|
149
|
+
if config.endpoint_url is not None
|
|
150
|
+
else "endpoint_url: null",
|
|
151
|
+
f'api_key: {_yaml_string(config.api_key)}'
|
|
152
|
+
if config.api_key is not None
|
|
153
|
+
else "api_key: null",
|
|
154
|
+
f"timeout_ms: {config.timeout_ms}",
|
|
155
|
+
f"retry_max: {config.retry_max}",
|
|
156
|
+
f"retry_backoff_ms: {config.retry_backoff_ms}",
|
|
157
|
+
f"max_output_bytes: {config.max_output_bytes}",
|
|
158
|
+
f"queue_size: {config.queue_size}",
|
|
159
|
+
f'shell_path: {_yaml_string(config.shell_path)}',
|
|
160
|
+
]
|
|
161
|
+
if config.metadata:
|
|
162
|
+
lines.append("metadata:")
|
|
163
|
+
for key, value in config.metadata.items():
|
|
164
|
+
lines.append(f" {key}: {_yaml_string(value)}")
|
|
165
|
+
return "\n".join(lines) + "\n"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _yaml_string(value: object) -> str:
|
|
169
|
+
escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
170
|
+
return f'"{escaped}"'
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _parse_simple_yaml(text: str) -> dict[str, object]:
|
|
174
|
+
result: dict[str, object] = {}
|
|
175
|
+
stack: list[tuple[int, dict[str, object]]] = [(-1, result)]
|
|
176
|
+
|
|
177
|
+
for raw_line in text.splitlines():
|
|
178
|
+
if not raw_line.strip():
|
|
179
|
+
continue
|
|
180
|
+
stripped = raw_line.lstrip()
|
|
181
|
+
if stripped.startswith("#"):
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
indent = len(raw_line) - len(stripped)
|
|
185
|
+
while len(stack) > 1 and indent <= stack[-1][0]:
|
|
186
|
+
stack.pop()
|
|
187
|
+
|
|
188
|
+
if ":" not in stripped:
|
|
189
|
+
continue
|
|
190
|
+
key, raw_value = stripped.split(":", 1)
|
|
191
|
+
key = key.strip()
|
|
192
|
+
value = raw_value.strip()
|
|
193
|
+
|
|
194
|
+
parent = stack[-1][1]
|
|
195
|
+
if not value:
|
|
196
|
+
child: dict[str, object] = {}
|
|
197
|
+
parent[key] = child
|
|
198
|
+
stack.append((indent, child))
|
|
199
|
+
continue
|
|
200
|
+
|
|
201
|
+
parent[key] = _parse_scalar(value)
|
|
202
|
+
|
|
203
|
+
return result
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _parse_scalar(value: str) -> object:
|
|
207
|
+
if value.startswith(("'", '"')) and value.endswith(("'", '"')) and len(value) >= 2:
|
|
208
|
+
return value[1:-1]
|
|
209
|
+
lowered = value.lower()
|
|
210
|
+
if lowered == "true":
|
|
211
|
+
return True
|
|
212
|
+
if lowered == "false":
|
|
213
|
+
return False
|
|
214
|
+
if lowered in {"null", "none"}:
|
|
215
|
+
return None
|
|
216
|
+
try:
|
|
217
|
+
return int(value)
|
|
218
|
+
except ValueError:
|
|
219
|
+
return value
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from logging.handlers import RotatingFileHandler
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def configure_logging(state_dir: Path) -> logging.Logger:
|
|
9
|
+
logger = logging.getLogger("cyber-shell")
|
|
10
|
+
if logger.handlers:
|
|
11
|
+
return logger
|
|
12
|
+
|
|
13
|
+
logger.setLevel(logging.INFO)
|
|
14
|
+
handler = RotatingFileHandler(
|
|
15
|
+
state_dir / "cyber-shell.log",
|
|
16
|
+
maxBytes=1_000_000,
|
|
17
|
+
backupCount=3,
|
|
18
|
+
encoding="utf-8",
|
|
19
|
+
)
|
|
20
|
+
handler.setFormatter(
|
|
21
|
+
logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
|
|
22
|
+
)
|
|
23
|
+
logger.addHandler(handler)
|
|
24
|
+
logger.propagate = False
|
|
25
|
+
return logger
|