splitagent 0.0.3__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.
- splitagent/__init__.py +8 -0
- splitagent/__main__.py +6 -0
- splitagent/agents/__init__.py +10 -0
- splitagent/agents/base.py +477 -0
- splitagent/agents/blue.py +57 -0
- splitagent/agents/chat.py +60 -0
- splitagent/agents/prompts.py +462 -0
- splitagent/agents/red.py +75 -0
- splitagent/cli.py +701 -0
- splitagent/config.py +697 -0
- splitagent/core/__init__.py +19 -0
- splitagent/core/bus.py +62 -0
- splitagent/core/context.py +587 -0
- splitagent/core/context_manager.py +381 -0
- splitagent/core/engine.py +424 -0
- splitagent/core/models.py +310 -0
- splitagent/core/proc.py +73 -0
- splitagent/core/sandbox.py +184 -0
- splitagent/core/toolbox.py +520 -0
- splitagent/core/workspace.py +420 -0
- splitagent/desktop/__init__.py +7 -0
- splitagent/desktop/api.py +525 -0
- splitagent/desktop/app.py +1131 -0
- splitagent/desktop/web/app.js +3067 -0
- splitagent/desktop/web/assets/Inter.ttf +0 -0
- splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- splitagent/desktop/web/index.html +760 -0
- splitagent/desktop/web/styles.css +1612 -0
- splitagent/errors.py +27 -0
- splitagent/llm/__init__.py +8 -0
- splitagent/llm/client.py +488 -0
- splitagent/llm/types.py +172 -0
- splitagent/report/__init__.py +9 -0
- splitagent/report/cvss.py +93 -0
- splitagent/report/generator.py +733 -0
- splitagent/tools/__init__.py +8 -0
- splitagent/tools/base.py +135 -0
- splitagent/tools/defense.py +475 -0
- splitagent/tools/exploit.py +318 -0
- splitagent/tools/http_pool.py +109 -0
- splitagent/tools/knowledge.py +376 -0
- splitagent/tools/recon.py +182 -0
- splitagent/tools/registry.py +62 -0
- splitagent/tools/validate.py +908 -0
- splitagent/tools/web.py +386 -0
- splitagent/tools/workspace_tools.py +411 -0
- splitagent/ui/__init__.py +5 -0
- splitagent/ui/app.py +389 -0
- splitagent/ui/stream.py +234 -0
- splitagent/ui/theme.py +72 -0
- splitagent-0.0.3.dist-info/METADATA +987 -0
- splitagent-0.0.3.dist-info/RECORD +56 -0
- splitagent-0.0.3.dist-info/WHEEL +5 -0
- splitagent-0.0.3.dist-info/entry_points.txt +2 -0
- splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
- splitagent-0.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""The central orchestrator: coordinates the Red/Blue rounds."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from splitagent.config import GlobalConfig, ProjectConfig
|
|
10
|
+
from splitagent.core.bus import Event, EventBus
|
|
11
|
+
from splitagent.core.context import SharedContext
|
|
12
|
+
from splitagent.core.sandbox import DockerSandbox, SandboxStatus
|
|
13
|
+
from splitagent.errors import ConfigError
|
|
14
|
+
from splitagent.llm.client import LLMClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Engine:
|
|
18
|
+
"""Drives a full purple-team audit session."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
global_config: GlobalConfig,
|
|
23
|
+
project: ProjectConfig,
|
|
24
|
+
bus: EventBus | None = None,
|
|
25
|
+
context: SharedContext | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.global_config = global_config
|
|
28
|
+
self.project = project
|
|
29
|
+
self.bus = bus or EventBus()
|
|
30
|
+
self.sandbox = DockerSandbox(project.run.sandbox, session_id="run")
|
|
31
|
+
self._sandbox_status: SandboxStatus | None = None
|
|
32
|
+
self.workspace: Any = None
|
|
33
|
+
self.toolbox: Any = None
|
|
34
|
+
self.toolbox_status: Any = None
|
|
35
|
+
self._toolbox_error = ""
|
|
36
|
+
target = project.target.url or ", ".join(project.target.effective_hosts())
|
|
37
|
+
self.context = context or SharedContext.create(
|
|
38
|
+
target=target or "unspecified",
|
|
39
|
+
target_kind=project.target.kind,
|
|
40
|
+
scope=project.target.scope or project.target.effective_hosts(),
|
|
41
|
+
name=project.name,
|
|
42
|
+
model=global_config.llm.model,
|
|
43
|
+
provider=global_config.llm.provider,
|
|
44
|
+
bus=self.bus,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# -- helpers ----------------------------------------------------------- #
|
|
48
|
+
def _require_llm(self) -> None:
|
|
49
|
+
from splitagent.config import KEYLESS_PROVIDERS
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
not self.global_config.llm.resolved_api_key()
|
|
53
|
+
and self.global_config.llm.provider not in KEYLESS_PROVIDERS
|
|
54
|
+
):
|
|
55
|
+
raise ConfigError(
|
|
56
|
+
"No API key configured. Run `splitagent config setup` to choose a "
|
|
57
|
+
"provider and model, or set the provider API key environment variable."
|
|
58
|
+
)
|
|
59
|
+
if not self.global_config.llm.model or not self.global_config.llm.base_url:
|
|
60
|
+
raise ConfigError("LLM model/base_url missing. Run `splitagent config setup`.")
|
|
61
|
+
|
|
62
|
+
def _configure_context(self) -> None:
|
|
63
|
+
"""Point the shared context at the active model and the workspace."""
|
|
64
|
+
from splitagent.agents.red import build_policy
|
|
65
|
+
from splitagent.core.workspace import workspace_for
|
|
66
|
+
|
|
67
|
+
self.context.policy = build_policy(self.project, self.global_config.llm)
|
|
68
|
+
workspace = workspace_for(self.project, base=Path.cwd())
|
|
69
|
+
workspace.ensure()
|
|
70
|
+
self.workspace = workspace
|
|
71
|
+
self.context.workspace = workspace
|
|
72
|
+
|
|
73
|
+
def _start_toolbox(self) -> None:
|
|
74
|
+
"""Bring up the isolated toolbox when the mode calls for it."""
|
|
75
|
+
from splitagent.core.toolbox import Toolbox, resolve_mode
|
|
76
|
+
|
|
77
|
+
config = self.project.run.execution
|
|
78
|
+
toolbox = Toolbox(config, self.workspace.root if self.workspace else None)
|
|
79
|
+
status = toolbox.detect()
|
|
80
|
+
self.toolbox_status = status
|
|
81
|
+
effective = resolve_mode(config, status)
|
|
82
|
+
if effective != "toolbox":
|
|
83
|
+
if config.mode == "toolbox":
|
|
84
|
+
raise ConfigError(
|
|
85
|
+
"Toolbox mode is enabled but Docker is not available. Install "
|
|
86
|
+
"Docker or switch run.execution.mode to 'auto'."
|
|
87
|
+
)
|
|
88
|
+
self.toolbox = None
|
|
89
|
+
return
|
|
90
|
+
if not status.running:
|
|
91
|
+
result = toolbox.up()
|
|
92
|
+
if not result.get("ok"):
|
|
93
|
+
self.toolbox = None
|
|
94
|
+
self._toolbox_error = result.get("error", "unknown error")
|
|
95
|
+
if config.mode == "toolbox":
|
|
96
|
+
raise ConfigError(f"Could not start the toolbox: {result.get('error')}")
|
|
97
|
+
return
|
|
98
|
+
self.toolbox = toolbox
|
|
99
|
+
|
|
100
|
+
def _target_is_external(self) -> bool:
|
|
101
|
+
"""True when the engagement points at a real, non-local server.
|
|
102
|
+
|
|
103
|
+
The sandbox exists to *provide* a disposable target. When the operator
|
|
104
|
+
has already given one - a domain, a public IP, a reachable host - the
|
|
105
|
+
sandbox would substitute the wrong thing: it would spin up Juice Shop
|
|
106
|
+
and audit that instead of the server they asked about.
|
|
107
|
+
"""
|
|
108
|
+
host = (self.project.target.effective_hosts() or [""])[0].lower()
|
|
109
|
+
if not host:
|
|
110
|
+
return False
|
|
111
|
+
if host in ("localhost", "127.0.0.1", "::1", "0.0.0.0"):
|
|
112
|
+
return False
|
|
113
|
+
try:
|
|
114
|
+
import ipaddress
|
|
115
|
+
|
|
116
|
+
address = ipaddress.ip_address(host)
|
|
117
|
+
return not (address.is_loopback or address.is_private or address.is_link_local)
|
|
118
|
+
except ValueError:
|
|
119
|
+
pass
|
|
120
|
+
# A hostname that is not a loopback alias is an external target.
|
|
121
|
+
return not host.endswith(".local")
|
|
122
|
+
|
|
123
|
+
async def _start_sandbox(self) -> None:
|
|
124
|
+
if not self.project.run.sandbox.enabled:
|
|
125
|
+
await self.bus.emit(Event(type="log", agent="core", data={"text": "Sandbox disabled."}))
|
|
126
|
+
return
|
|
127
|
+
if self._target_is_external():
|
|
128
|
+
# Auditing a real server: never start the lab container.
|
|
129
|
+
self._sandbox_status = None
|
|
130
|
+
await self.bus.emit(
|
|
131
|
+
Event(
|
|
132
|
+
type="log",
|
|
133
|
+
agent="core",
|
|
134
|
+
data={
|
|
135
|
+
"text": (
|
|
136
|
+
f"Target {self.project.target.effective_hosts()[0]} is a real "
|
|
137
|
+
"server, so no sandbox is started. Disable run.sandbox to "
|
|
138
|
+
"silence this."
|
|
139
|
+
),
|
|
140
|
+
"level": "info",
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return
|
|
145
|
+
status = await self.sandbox.up()
|
|
146
|
+
self._sandbox_status = status
|
|
147
|
+
if status.running and status.url and not self.project.target.url:
|
|
148
|
+
self.project.target.url = status.url
|
|
149
|
+
# The context was built before the sandbox existed; keep the target
|
|
150
|
+
# the session records in sync with the URL we actually test.
|
|
151
|
+
self.context.state.target = status.url
|
|
152
|
+
await self.bus.emit(
|
|
153
|
+
Event(
|
|
154
|
+
type="log",
|
|
155
|
+
agent="core",
|
|
156
|
+
data={
|
|
157
|
+
"text": f"Sandbox: {status.message}"
|
|
158
|
+
+ (f" -> {status.url}" if status.url else ""),
|
|
159
|
+
"level": "info" if status.available else "warning",
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def _stop_sandbox(self) -> None:
|
|
165
|
+
if self._sandbox_status and self._sandbox_status.running:
|
|
166
|
+
await self.sandbox.down()
|
|
167
|
+
await self.bus.emit(Event(type="log", agent="core", data={"text": "Sandbox removed."}))
|
|
168
|
+
|
|
169
|
+
def _agent_settings(self) -> dict[str, Any]:
|
|
170
|
+
return {
|
|
171
|
+
"sandbox": self.sandbox if self.project.run.sandbox.enabled else None,
|
|
172
|
+
"auth_headers": self.project.auth.as_headers(),
|
|
173
|
+
"workspace": self.context.workspace,
|
|
174
|
+
"project": self.project,
|
|
175
|
+
"toolbox": self.toolbox,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# -- main entry point -------------------------------------------------- #
|
|
179
|
+
async def run(self) -> SharedContext:
|
|
180
|
+
self._require_llm()
|
|
181
|
+
self._configure_context()
|
|
182
|
+
await self.bus.emit(
|
|
183
|
+
Event(
|
|
184
|
+
type="workspace",
|
|
185
|
+
agent="core",
|
|
186
|
+
data={
|
|
187
|
+
"root": str(self.workspace.root),
|
|
188
|
+
"installers": __import__(
|
|
189
|
+
"splitagent.core.workspace", fromlist=["available_installers"]
|
|
190
|
+
).available_installers(),
|
|
191
|
+
"allow_install": self.project.workspace.allow_install,
|
|
192
|
+
"allow_external_tools": self.project.workspace.allow_external_tools,
|
|
193
|
+
"file_counts": self.workspace.stats(),
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
await self.bus.emit(
|
|
198
|
+
Event(
|
|
199
|
+
type="context.policy",
|
|
200
|
+
agent="core",
|
|
201
|
+
data={
|
|
202
|
+
"context_limit": self.context.policy.context_limit,
|
|
203
|
+
"output_token_max": self.context.policy.output_token_max,
|
|
204
|
+
"usable": __import__(
|
|
205
|
+
"splitagent.core.context_manager", fromlist=["usable"]
|
|
206
|
+
).usable(self.context.policy),
|
|
207
|
+
"prune": self.context.policy.prune,
|
|
208
|
+
"auto_compact": self.context.policy.auto,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
await self.bus.emit(
|
|
213
|
+
Event(
|
|
214
|
+
type="session.start",
|
|
215
|
+
agent="core",
|
|
216
|
+
data={
|
|
217
|
+
"session": self.context.state.id,
|
|
218
|
+
"target": self.context.state.target,
|
|
219
|
+
"model": self.global_config.llm.model,
|
|
220
|
+
"provider": self.global_config.llm.provider,
|
|
221
|
+
"rounds": self.project.run.rounds,
|
|
222
|
+
},
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
self._start_toolbox()
|
|
228
|
+
await self.bus.emit(
|
|
229
|
+
Event(
|
|
230
|
+
type="toolbox",
|
|
231
|
+
agent="core",
|
|
232
|
+
data={
|
|
233
|
+
"mode": self.project.run.execution.mode,
|
|
234
|
+
"edition": self.project.run.execution.edition,
|
|
235
|
+
"active": self.toolbox is not None,
|
|
236
|
+
"error": self._toolbox_error,
|
|
237
|
+
"status": self.toolbox_status.to_dict() if self.toolbox_status else {},
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
await self._start_sandbox()
|
|
242
|
+
async with LLMClient(self.global_config.llm) as client:
|
|
243
|
+
await self._run_with_deadline(client)
|
|
244
|
+
finally:
|
|
245
|
+
from splitagent.tools.http_pool import aclose_all
|
|
246
|
+
|
|
247
|
+
await aclose_all()
|
|
248
|
+
await self._stop_sandbox()
|
|
249
|
+
self.context.save()
|
|
250
|
+
await self.bus.emit(
|
|
251
|
+
Event(
|
|
252
|
+
type="session.end",
|
|
253
|
+
agent="core",
|
|
254
|
+
data=self.context.summary_dict(),
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
return self.context
|
|
258
|
+
|
|
259
|
+
async def _run_with_deadline(self, client: LLMClient) -> None:
|
|
260
|
+
"""Run the rounds under a wall-clock ceiling.
|
|
261
|
+
|
|
262
|
+
An open-ended engagement can hang for hours on a slow target or a
|
|
263
|
+
provider that never answers. Hitting the deadline stops cleanly with
|
|
264
|
+
everything already persisted, instead of being killed by the operator.
|
|
265
|
+
"""
|
|
266
|
+
minutes = max(0, int(self.project.run.max_duration_minutes))
|
|
267
|
+
if minutes <= 0:
|
|
268
|
+
await self._run_rounds(client)
|
|
269
|
+
return
|
|
270
|
+
try:
|
|
271
|
+
await asyncio.wait_for(self._run_rounds(client), timeout=minutes * 60)
|
|
272
|
+
except asyncio.TimeoutError:
|
|
273
|
+
await self.bus.emit(
|
|
274
|
+
Event(
|
|
275
|
+
type="error",
|
|
276
|
+
agent="core",
|
|
277
|
+
data={
|
|
278
|
+
"text": (
|
|
279
|
+
f"Run stopped: the {minutes}-minute limit was reached. "
|
|
280
|
+
"Everything confirmed so far is saved - raise "
|
|
281
|
+
"run.max_duration_minutes to go further."
|
|
282
|
+
)
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
self.context.state.notes.append(f"Stopped at the {minutes}-minute deadline.")
|
|
287
|
+
|
|
288
|
+
async def _run_rounds(self, client: LLMClient) -> None:
|
|
289
|
+
total = max(1, self.project.run.rounds)
|
|
290
|
+
previous_blue_summary = ""
|
|
291
|
+
for index in range(1, total + 1):
|
|
292
|
+
round_ = self.context.begin_round(index)
|
|
293
|
+
await self.bus.emit(
|
|
294
|
+
Event(type="round.start", agent="core", data={"round": index, "total": total})
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if self.project.agents.red.enabled:
|
|
298
|
+
red_summary = await self._run_red(client, index, total, previous_blue_summary)
|
|
299
|
+
round_.red_summary = red_summary
|
|
300
|
+
round_.finding_ids = [f.id for f in self.context.state.findings if f.round == index]
|
|
301
|
+
else:
|
|
302
|
+
red_summary = "(red agent disabled)"
|
|
303
|
+
|
|
304
|
+
if self.project.agents.blue.enabled:
|
|
305
|
+
blue_summary = await self._run_blue(client, index, total, red_summary)
|
|
306
|
+
round_.blue_summary = blue_summary
|
|
307
|
+
round_.mitigation_ids = [
|
|
308
|
+
m.id for m in self.context.state.mitigations if m.round == index
|
|
309
|
+
]
|
|
310
|
+
else:
|
|
311
|
+
blue_summary = "(blue agent disabled)"
|
|
312
|
+
|
|
313
|
+
previous_blue_summary = blue_summary
|
|
314
|
+
self.context.end_round(index, red_summary, blue_summary)
|
|
315
|
+
self.context.save()
|
|
316
|
+
await self.bus.emit(
|
|
317
|
+
Event(
|
|
318
|
+
type="round.end",
|
|
319
|
+
agent="core",
|
|
320
|
+
data={
|
|
321
|
+
"round": index,
|
|
322
|
+
"findings": len(self.context.state.findings),
|
|
323
|
+
"mitigations": len(self.context.state.mitigations),
|
|
324
|
+
"resilience": self.context.state.resilience_score(),
|
|
325
|
+
},
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
self.context.state.ended_at = _now()
|
|
330
|
+
|
|
331
|
+
async def _run_red(self, client: LLMClient, index: int, total: int, previous_blue: str) -> str:
|
|
332
|
+
from splitagent.agents.red import RedAgent
|
|
333
|
+
|
|
334
|
+
agent = RedAgent(
|
|
335
|
+
client=client,
|
|
336
|
+
context=self.context,
|
|
337
|
+
project=self.project,
|
|
338
|
+
bus=self.bus,
|
|
339
|
+
round_index=index,
|
|
340
|
+
total_rounds=total,
|
|
341
|
+
settings=self._agent_settings(),
|
|
342
|
+
)
|
|
343
|
+
await self.bus.emit(
|
|
344
|
+
Event(type="phase.start", agent="red", data={"round": index, "phase": "offense"})
|
|
345
|
+
)
|
|
346
|
+
task = (
|
|
347
|
+
f"Round {index}/{total}. Perform reconnaissance and controlled "
|
|
348
|
+
"exploitation against the target, then persist every confirmed "
|
|
349
|
+
"finding with evidence."
|
|
350
|
+
)
|
|
351
|
+
if previous_blue:
|
|
352
|
+
task += (
|
|
353
|
+
"\n\nDefences applied by the Blue Agent in the previous round "
|
|
354
|
+
"(test whether they hold):\n" + previous_blue
|
|
355
|
+
)
|
|
356
|
+
result = await agent.run(task)
|
|
357
|
+
self._accumulate_usage(result.usage)
|
|
358
|
+
await self.context.add_trace("red", result.trace)
|
|
359
|
+
await self.bus.emit(
|
|
360
|
+
Event(
|
|
361
|
+
type="phase.end",
|
|
362
|
+
agent="red",
|
|
363
|
+
data={
|
|
364
|
+
"round": index,
|
|
365
|
+
"phase": "offense",
|
|
366
|
+
"summary": result.text,
|
|
367
|
+
"tool_calls": result.tool_calls,
|
|
368
|
+
"pruned": result.pruned,
|
|
369
|
+
"compactions": result.compactions,
|
|
370
|
+
},
|
|
371
|
+
)
|
|
372
|
+
)
|
|
373
|
+
return result.text
|
|
374
|
+
|
|
375
|
+
async def _run_blue(self, client: LLMClient, index: int, total: int, red_summary: str) -> str:
|
|
376
|
+
from splitagent.agents.blue import BlueAgent
|
|
377
|
+
|
|
378
|
+
agent = BlueAgent(
|
|
379
|
+
client=client,
|
|
380
|
+
context=self.context,
|
|
381
|
+
project=self.project,
|
|
382
|
+
bus=self.bus,
|
|
383
|
+
round_index=index,
|
|
384
|
+
total_rounds=total,
|
|
385
|
+
settings=self._agent_settings(),
|
|
386
|
+
)
|
|
387
|
+
await self.bus.emit(
|
|
388
|
+
Event(type="phase.start", agent="blue", data={"round": index, "phase": "defense"})
|
|
389
|
+
)
|
|
390
|
+
task = (
|
|
391
|
+
f"Round {index}/{total}. The Red Agent just reported:\n\n"
|
|
392
|
+
f"{red_summary}\n\n"
|
|
393
|
+
"Triage telemetry, produce countermeasures for the open findings and "
|
|
394
|
+
"verify them where possible."
|
|
395
|
+
)
|
|
396
|
+
result = await agent.run(task)
|
|
397
|
+
self._accumulate_usage(result.usage)
|
|
398
|
+
await self.context.add_trace("blue", result.trace)
|
|
399
|
+
await self.bus.emit(
|
|
400
|
+
Event(
|
|
401
|
+
type="phase.end",
|
|
402
|
+
agent="blue",
|
|
403
|
+
data={
|
|
404
|
+
"round": index,
|
|
405
|
+
"phase": "defense",
|
|
406
|
+
"summary": result.text,
|
|
407
|
+
"tool_calls": result.tool_calls,
|
|
408
|
+
"pruned": result.pruned,
|
|
409
|
+
"compactions": result.compactions,
|
|
410
|
+
},
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
return result.text
|
|
414
|
+
|
|
415
|
+
def _accumulate_usage(self, usage: dict[str, Any]) -> None:
|
|
416
|
+
for key, value in usage.items():
|
|
417
|
+
if isinstance(value, int):
|
|
418
|
+
self.context.state.usage[key] = self.context.state.usage.get(key, 0) + value
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _now() -> str:
|
|
422
|
+
from datetime import datetime, timezone
|
|
423
|
+
|
|
424
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|