aisoc-sandbox 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,154 @@
1
+ Metadata-Version: 2.4
2
+ Name: aisoc-sandbox
3
+ Version: 0.1.0
4
+ Summary: Run an AiSOC agent investigation offline in under 30 seconds. No Docker, no API key.
5
+ Author: AiSOC contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/beenuar/AiSOC
8
+ Project-URL: Documentation, https://beenuar.github.io/AiSOC/
9
+ Project-URL: Source, https://github.com/beenuar/AiSOC/tree/main/packages/aisoc-sandbox
10
+ Project-URL: Issues, https://github.com/beenuar/AiSOC/issues
11
+ Project-URL: Changelog, https://github.com/beenuar/AiSOC/blob/main/CHANGELOG.md
12
+ Keywords: aisoc,soc,security,agent,investigation,ledger,offline
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Information Technology
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Security
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8.0; extra == "dev"
26
+ Requires-Dist: pytest-cov>=4.1; extra == "dev"
27
+
28
+ # aisoc-sandbox
29
+
30
+ > Run an AiSOC agent investigation **offline in under 30 seconds**. No Docker, no API key, no network.
31
+
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-22c55e.svg)](https://github.com/beenuar/AiSOC/blob/main/LICENSE)
33
+ [![PyPI release](https://img.shields.io/badge/pypi-not%20yet%20published-f59e0b)](https://github.com/beenuar/AiSOC/blob/main/CHANGELOG.md)
34
+
35
+ `aisoc-sandbox` is the quickest possible on-ramp to [AiSOC](https://github.com/beenuar/AiSOC). It walks one alert fixture through a four-stage agent funnel — **Detect → Triage → Hunt → Respond** — using a deterministic offline reasoner in place of a real LLM, and prints the resulting Investigation Ledger to your terminal.
36
+
37
+ It is the simulator-equivalent of the production [`services/agents/`](https://github.com/beenuar/AiSOC/tree/main/services/agents) graph, collapsed into a single zero-dependency Python package. **When you're ready to run the real stack: `pnpm aisoc:demo` from a fresh clone of [AiSOC](https://github.com/beenuar/AiSOC).**
38
+
39
+ ## Why this exists
40
+
41
+ The production AiSOC stack needs Postgres, Kafka, Redis, an LLM API key, and ~5 minutes to boot. That's the right cost for a buyer evaluating against their own alert data — but it's the wrong cost for a developer who just wants to see how the agent reasons before they commit their evening.
42
+
43
+ This package collapses the boot time to **< 5 seconds** and the disk footprint to **< 50 KB**. The trade-off is that the reasoning is deterministic and the tools are simulated (not executed); see "Differences from the production stack" below.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ # Today (from this monorepo):
49
+ git clone https://github.com/beenuar/AiSOC.git
50
+ cd AiSOC && pip install -e packages/aisoc-sandbox
51
+
52
+ # Once published to PyPI (ready, unpublished — the upload is blocked on
53
+ # registry credentials, which is an account action, not a code change):
54
+ # pip install aisoc-sandbox
55
+ # pipx run aisoc-sandbox demo
56
+ ```
57
+
58
+ Python 3.10+ on Linux / macOS / Windows. Zero runtime dependencies.
59
+
60
+ ## Quick start
61
+
62
+ ```bash
63
+ # Walk the default scenario (lateral-movement) through the funnel
64
+ aisoc-sandbox demo
65
+
66
+ # Pick a different bundled scenario
67
+ aisoc-sandbox demo --scenario aws-credential-exfil
68
+
69
+ # Use your own scenario JSON
70
+ aisoc-sandbox demo --file ./my-alert.json
71
+
72
+ # Machine-readable output
73
+ aisoc-sandbox demo --scenario phishing-payload --json | jq
74
+
75
+ # What scenarios are bundled?
76
+ aisoc-sandbox scenarios
77
+ ```
78
+
79
+ ## Bundled scenarios
80
+
81
+ Five scenarios ship with the package; each one is a single JSON file under [`src/aisoc_sandbox/scenarios/`](./src/aisoc_sandbox/scenarios) and is small enough to read end-to-end:
82
+
83
+ | ID | Title | MITRE | Severity |
84
+ |---|---|---|---|
85
+ | `lateral-movement` | Impossible-travel Okta sign-in | T1078, T1078.004 | high |
86
+ | `aws-credential-exfil` | IAM keys used from new ASN, then `s3:GetObject` flood | T1552, T1567, T1078.004 | critical |
87
+ | `phishing-payload` | Click-through to credential-harvest page | T1566, T1566.002 | high |
88
+ | `kubernetes-privesc` | Namespace SA bound to `cluster-admin` | T1098, T1078 | critical |
89
+ | `github-token-theft` | PAT leaked, six private repos cloned in 11 s | T1078, T1555, T1567 | high |
90
+
91
+ ## What you'll see
92
+
93
+ Each `aisoc-sandbox demo` run emits a four-step ledger:
94
+
95
+ ```
96
+ Investigation Ledger
97
+ 4 steps · 12 ms total · synthetic offline run (no LLM, no Docker)
98
+
99
+ Step 0 DETECT DetectAgent (3 ms)
100
+ Action Match incoming events against detection ruleset
101
+ Rationale 2 event(s) ingested; matched detection ruleset against MITRE techniques T1078, T1078.004.
102
+ · events_ingested: 2
103
+ · mitre_techniques: ['T1078', 'T1078.004']
104
+ · severity_at_intake: high
105
+ · entity:user: alice@example.com
106
+ → would-call rules.match({"rule_count": "800+", ...})
107
+ → would-call fusion.score({"window_minutes": 15})
108
+ Decision Open alert at severity=high
109
+
110
+ Step 1 TRIAGE TriageAgent (3 ms)
111
+ Action Score alert confidence + cross-reference with prior cases
112
+ Rationale Authenticated session signals look legitimate at the protocol layer, but the geo pivot between sequential events is physically impossible — classic credential takeover.
113
+ ...
114
+ ```
115
+
116
+ The shape mirrors the production Investigation Rail at [`/alerts/[id]`](https://github.com/beenuar/AiSOC/blob/main/apps/docs/docs/console/investigation-rail.md). The four stages, the evidence chips, and the "Decision" lines are the same — only the LLM rationale and tool execution are simulated.
117
+
118
+ ## Library use
119
+
120
+ The package's surface is small enough to embed:
121
+
122
+ ```python
123
+ from aisoc_sandbox import load_scenario, run_investigation
124
+
125
+ scenario = load_scenario("aws-credential-exfil")
126
+ ledger = run_investigation(scenario)
127
+
128
+ # Iterate the steps
129
+ for step in ledger:
130
+ print(step.step, step.agent, step.action, step.decision)
131
+
132
+ # Or render to a stream (TTY-aware colour)
133
+ ledger.render_human()
134
+
135
+ # Or serialise
136
+ print(ledger.to_json())
137
+ ```
138
+
139
+ ## Differences from the production stack
140
+
141
+ | | `aisoc-sandbox` | Production `services/agents/` |
142
+ |---|---|---|
143
+ | LLM | Deterministic template-driven stub | OpenAI / Anthropic / Ollama via LiteLLM |
144
+ | Tool calls | Simulated as "would-call(name, args)" | Dispatched to connector / action services |
145
+ | Persistence | In-memory; one CLI invocation | Postgres `investigation_events` table |
146
+ | Latency | Synthetic per-stage numbers | Real LLM + tool latency |
147
+ | Boot time | < 5 s | ~5 min cold, ~3.5 min warm |
148
+ | Dependencies | None | Postgres + Kafka + Redis + LLM API key |
149
+
150
+ This is on purpose. The sandbox is the **on-ramp**, not a replacement: it gives you 30-second visibility into how the funnel hangs together so you can decide whether the full demo is worth the 5-minute boot.
151
+
152
+ ## License
153
+
154
+ MIT — see the [repo LICENSE](https://github.com/beenuar/AiSOC/blob/main/LICENSE).
@@ -0,0 +1,127 @@
1
+ # aisoc-sandbox
2
+
3
+ > Run an AiSOC agent investigation **offline in under 30 seconds**. No Docker, no API key, no network.
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-22c55e.svg)](https://github.com/beenuar/AiSOC/blob/main/LICENSE)
6
+ [![PyPI release](https://img.shields.io/badge/pypi-not%20yet%20published-f59e0b)](https://github.com/beenuar/AiSOC/blob/main/CHANGELOG.md)
7
+
8
+ `aisoc-sandbox` is the quickest possible on-ramp to [AiSOC](https://github.com/beenuar/AiSOC). It walks one alert fixture through a four-stage agent funnel — **Detect → Triage → Hunt → Respond** — using a deterministic offline reasoner in place of a real LLM, and prints the resulting Investigation Ledger to your terminal.
9
+
10
+ It is the simulator-equivalent of the production [`services/agents/`](https://github.com/beenuar/AiSOC/tree/main/services/agents) graph, collapsed into a single zero-dependency Python package. **When you're ready to run the real stack: `pnpm aisoc:demo` from a fresh clone of [AiSOC](https://github.com/beenuar/AiSOC).**
11
+
12
+ ## Why this exists
13
+
14
+ The production AiSOC stack needs Postgres, Kafka, Redis, an LLM API key, and ~5 minutes to boot. That's the right cost for a buyer evaluating against their own alert data — but it's the wrong cost for a developer who just wants to see how the agent reasons before they commit their evening.
15
+
16
+ This package collapses the boot time to **< 5 seconds** and the disk footprint to **< 50 KB**. The trade-off is that the reasoning is deterministic and the tools are simulated (not executed); see "Differences from the production stack" below.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ # Today (from this monorepo):
22
+ git clone https://github.com/beenuar/AiSOC.git
23
+ cd AiSOC && pip install -e packages/aisoc-sandbox
24
+
25
+ # Once published to PyPI (ready, unpublished — the upload is blocked on
26
+ # registry credentials, which is an account action, not a code change):
27
+ # pip install aisoc-sandbox
28
+ # pipx run aisoc-sandbox demo
29
+ ```
30
+
31
+ Python 3.10+ on Linux / macOS / Windows. Zero runtime dependencies.
32
+
33
+ ## Quick start
34
+
35
+ ```bash
36
+ # Walk the default scenario (lateral-movement) through the funnel
37
+ aisoc-sandbox demo
38
+
39
+ # Pick a different bundled scenario
40
+ aisoc-sandbox demo --scenario aws-credential-exfil
41
+
42
+ # Use your own scenario JSON
43
+ aisoc-sandbox demo --file ./my-alert.json
44
+
45
+ # Machine-readable output
46
+ aisoc-sandbox demo --scenario phishing-payload --json | jq
47
+
48
+ # What scenarios are bundled?
49
+ aisoc-sandbox scenarios
50
+ ```
51
+
52
+ ## Bundled scenarios
53
+
54
+ Five scenarios ship with the package; each one is a single JSON file under [`src/aisoc_sandbox/scenarios/`](./src/aisoc_sandbox/scenarios) and is small enough to read end-to-end:
55
+
56
+ | ID | Title | MITRE | Severity |
57
+ |---|---|---|---|
58
+ | `lateral-movement` | Impossible-travel Okta sign-in | T1078, T1078.004 | high |
59
+ | `aws-credential-exfil` | IAM keys used from new ASN, then `s3:GetObject` flood | T1552, T1567, T1078.004 | critical |
60
+ | `phishing-payload` | Click-through to credential-harvest page | T1566, T1566.002 | high |
61
+ | `kubernetes-privesc` | Namespace SA bound to `cluster-admin` | T1098, T1078 | critical |
62
+ | `github-token-theft` | PAT leaked, six private repos cloned in 11 s | T1078, T1555, T1567 | high |
63
+
64
+ ## What you'll see
65
+
66
+ Each `aisoc-sandbox demo` run emits a four-step ledger:
67
+
68
+ ```
69
+ Investigation Ledger
70
+ 4 steps · 12 ms total · synthetic offline run (no LLM, no Docker)
71
+
72
+ Step 0 DETECT DetectAgent (3 ms)
73
+ Action Match incoming events against detection ruleset
74
+ Rationale 2 event(s) ingested; matched detection ruleset against MITRE techniques T1078, T1078.004.
75
+ · events_ingested: 2
76
+ · mitre_techniques: ['T1078', 'T1078.004']
77
+ · severity_at_intake: high
78
+ · entity:user: alice@example.com
79
+ → would-call rules.match({"rule_count": "800+", ...})
80
+ → would-call fusion.score({"window_minutes": 15})
81
+ Decision Open alert at severity=high
82
+
83
+ Step 1 TRIAGE TriageAgent (3 ms)
84
+ Action Score alert confidence + cross-reference with prior cases
85
+ Rationale Authenticated session signals look legitimate at the protocol layer, but the geo pivot between sequential events is physically impossible — classic credential takeover.
86
+ ...
87
+ ```
88
+
89
+ The shape mirrors the production Investigation Rail at [`/alerts/[id]`](https://github.com/beenuar/AiSOC/blob/main/apps/docs/docs/console/investigation-rail.md). The four stages, the evidence chips, and the "Decision" lines are the same — only the LLM rationale and tool execution are simulated.
90
+
91
+ ## Library use
92
+
93
+ The package's surface is small enough to embed:
94
+
95
+ ```python
96
+ from aisoc_sandbox import load_scenario, run_investigation
97
+
98
+ scenario = load_scenario("aws-credential-exfil")
99
+ ledger = run_investigation(scenario)
100
+
101
+ # Iterate the steps
102
+ for step in ledger:
103
+ print(step.step, step.agent, step.action, step.decision)
104
+
105
+ # Or render to a stream (TTY-aware colour)
106
+ ledger.render_human()
107
+
108
+ # Or serialise
109
+ print(ledger.to_json())
110
+ ```
111
+
112
+ ## Differences from the production stack
113
+
114
+ | | `aisoc-sandbox` | Production `services/agents/` |
115
+ |---|---|---|
116
+ | LLM | Deterministic template-driven stub | OpenAI / Anthropic / Ollama via LiteLLM |
117
+ | Tool calls | Simulated as "would-call(name, args)" | Dispatched to connector / action services |
118
+ | Persistence | In-memory; one CLI invocation | Postgres `investigation_events` table |
119
+ | Latency | Synthetic per-stage numbers | Real LLM + tool latency |
120
+ | Boot time | < 5 s | ~5 min cold, ~3.5 min warm |
121
+ | Dependencies | None | Postgres + Kafka + Redis + LLM API key |
122
+
123
+ This is on purpose. The sandbox is the **on-ramp**, not a replacement: it gives you 30-second visibility into how the funnel hangs together so you can decide whether the full demo is worth the 5-minute boot.
124
+
125
+ ## License
126
+
127
+ MIT — see the [repo LICENSE](https://github.com/beenuar/AiSOC/blob/main/LICENSE).
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "aisoc-sandbox"
7
+ version = "0.1.0"
8
+ description = "Run an AiSOC agent investigation offline in under 30 seconds. No Docker, no API key."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "AiSOC contributors" }]
13
+ keywords = ["aisoc", "soc", "security", "agent", "investigation", "ledger", "offline"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Information Technology",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Security",
24
+ ]
25
+
26
+ # Zero runtime dependencies on purpose. The whole point of the sandbox
27
+ # is that `pip install aisoc-sandbox && aisoc-sandbox demo` works on a
28
+ # clean machine inside 30 seconds.
29
+ dependencies = []
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0",
34
+ "pytest-cov>=4.1",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/beenuar/AiSOC"
39
+ Documentation = "https://beenuar.github.io/AiSOC/"
40
+ Source = "https://github.com/beenuar/AiSOC/tree/main/packages/aisoc-sandbox"
41
+ Issues = "https://github.com/beenuar/AiSOC/issues"
42
+ Changelog = "https://github.com/beenuar/AiSOC/blob/main/CHANGELOG.md"
43
+
44
+ [project.scripts]
45
+ aisoc-sandbox = "aisoc_sandbox.cli:main"
46
+
47
+ [tool.setuptools]
48
+ package-dir = { "" = "src" }
49
+
50
+ [tool.setuptools.packages.find]
51
+ where = ["src"]
52
+
53
+ [tool.setuptools.package-data]
54
+ aisoc_sandbox = ["scenarios/*.json", "py.typed"]
55
+
56
+ [tool.pytest.ini_options]
57
+ addopts = "-ra --strict-markers"
58
+ testpaths = ["tests"]
59
+
60
+ # Same shape as packages/sdk-py, plugin-sdk-py and aisoc-cli — the three
61
+ # package trees that already declare one. `python_version` is pinned to 3.11
62
+ # even though the wheel supports 3.10+: the baseline has to be reproducible,
63
+ # and 3.11 is the interpreter CI runs.
64
+ [tool.mypy]
65
+ strict = true
66
+ python_version = "3.11"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,42 @@
1
+ """aisoc-sandbox: run an AiSOC agent investigation offline in under 30 seconds.
2
+
3
+ This package is the quickest possible on-ramp to AiSOC: no Docker, no
4
+ Postgres / Kafka / Redis, no API key, no network — `aisoc-sandbox demo`
5
+ walks one alert fixture through a Detect → Triage → Hunt → Respond
6
+ agent funnel and prints a step-by-step Investigation Ledger to stdout.
7
+
8
+ It is a deterministic, in-memory simulator of the production stack at
9
+ [`services/agents/`](https://github.com/beenuar/AiSOC/tree/main/services/agents).
10
+ The shape of the ledger, the funnel stages, the decision metadata, and
11
+ the recommended actions all mirror what the real four-agent system in
12
+ the monorepo emits. The simulator is intentionally NOT the production
13
+ graph: the production graph requires Postgres + Kafka + an LLM API
14
+ key, and a 30-second offline demo cannot afford any of those.
15
+
16
+ When you're ready to run the real stack: `pnpm aisoc:demo`.
17
+
18
+ Public entry points:
19
+
20
+ - :class:`Investigation` — one investigation run.
21
+ - :class:`Ledger` — the step-by-step record.
22
+ - :func:`load_scenario` — load a built-in or user-supplied scenario.
23
+ - :func:`run_investigation` — high-level orchestrator (used by the CLI).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from .investigation import Investigation, run_investigation
29
+ from .ledger import Ledger, LedgerStep
30
+ from .scenarios import Scenario, available_scenarios, load_scenario
31
+
32
+ __all__ = [
33
+ "Investigation",
34
+ "Ledger",
35
+ "LedgerStep",
36
+ "Scenario",
37
+ "available_scenarios",
38
+ "load_scenario",
39
+ "run_investigation",
40
+ ]
41
+
42
+ __version__ = "0.1.0"
@@ -0,0 +1,150 @@
1
+ """aisoc-sandbox — command-line entry point.
2
+
3
+ The two visible subcommands are ``demo`` and ``scenarios``:
4
+
5
+ * ``aisoc-sandbox demo`` runs one full Detect → Triage → Hunt →
6
+ Respond funnel against a bundled or user-supplied scenario and
7
+ prints the Investigation Ledger to stdout. ``--json`` switches
8
+ the output to a machine-readable form.
9
+
10
+ * ``aisoc-sandbox scenarios`` lists the bundled scenarios so a user
11
+ can pick one with ``--scenario <id>`` without reading the docs.
12
+
13
+ Exit codes follow UNIX convention:
14
+
15
+ 0 success — investigation ran and the ledger was emitted.
16
+ 2 invalid arguments (bad scenario id, missing file, etc).
17
+ 3 internal error during the run.
18
+
19
+ There is no networked path in this CLI. If you `strace` it you should
20
+ see exactly one ``read`` of the scenario file, zero ``connect`` syscalls.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import sys
28
+ import time
29
+ from typing import Sequence
30
+
31
+ from . import __version__
32
+ from .investigation import run_investigation
33
+ from .ledger import Ledger
34
+ from .scenarios import available_scenarios, emit_scenario_index, load_scenario
35
+
36
+
37
+ _PROG = "aisoc-sandbox"
38
+ _DESCRIPTION = "Run an AiSOC agent investigation offline in under 30 seconds. No Docker, no API key, no network."
39
+
40
+
41
+ def _build_parser() -> argparse.ArgumentParser:
42
+ p = argparse.ArgumentParser(
43
+ prog=_PROG,
44
+ description=_DESCRIPTION,
45
+ formatter_class=argparse.RawDescriptionHelpFormatter,
46
+ epilog=(
47
+ "Examples:\n"
48
+ " aisoc-sandbox demo\n"
49
+ " aisoc-sandbox demo --scenario aws-credential-exfil\n"
50
+ " aisoc-sandbox demo --file my-alert.json --json\n"
51
+ " aisoc-sandbox scenarios\n"
52
+ ),
53
+ )
54
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
55
+ sub = p.add_subparsers(dest="command", required=True, metavar="<command>")
56
+
57
+ demo = sub.add_parser(
58
+ "demo",
59
+ help="Walk one scenario through the Detect → Triage → Hunt → Respond funnel.",
60
+ )
61
+ demo.add_argument(
62
+ "--scenario",
63
+ choices=available_scenarios(),
64
+ default="lateral-movement",
65
+ help="Bundled scenario id (default: %(default)s).",
66
+ )
67
+ demo.add_argument(
68
+ "--file",
69
+ metavar="PATH",
70
+ help="Path to a custom scenario JSON. Overrides --scenario when set.",
71
+ )
72
+ demo.add_argument(
73
+ "--json",
74
+ action="store_true",
75
+ help="Emit the ledger as a JSON document instead of the human view.",
76
+ )
77
+
78
+ sub.add_parser(
79
+ "scenarios",
80
+ help="List the bundled scenarios and exit.",
81
+ )
82
+ return p
83
+
84
+
85
+ def main(argv: Sequence[str] | None = None) -> int:
86
+ parser = _build_parser()
87
+ args = parser.parse_args(argv)
88
+
89
+ if args.command == "scenarios":
90
+ emit_scenario_index()
91
+ return 0
92
+
93
+ if args.command == "demo":
94
+ try:
95
+ scenario = load_scenario(args.scenario, file=args.file)
96
+ except (FileNotFoundError, ValueError) as exc:
97
+ print(f"{_PROG}: error: {exc}", file=sys.stderr)
98
+ return 2
99
+
100
+ ledger = Ledger()
101
+ started = time.perf_counter()
102
+ try:
103
+ run_investigation(scenario, ledger=ledger)
104
+ except Exception as exc: # noqa: BLE001 — surface to the user, exit 3.
105
+ print(f"{_PROG}: investigation crashed: {exc}", file=sys.stderr)
106
+ return 3
107
+ elapsed_ms = int((time.perf_counter() - started) * 1000)
108
+
109
+ if args.json:
110
+ payload = {
111
+ "tool": _PROG,
112
+ "version": __version__,
113
+ "scenario": scenario.to_dict(),
114
+ "ledger": ledger.to_dict(),
115
+ "elapsed_ms": elapsed_ms,
116
+ }
117
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
118
+ return 0
119
+
120
+ # Human view: brief preamble, then the rendered ledger.
121
+ _print_preamble(scenario)
122
+ ledger.render_human()
123
+ print(
124
+ f"Ran {len(ledger)} steps in {elapsed_ms} ms.\n"
125
+ "Ready for the real stack? `pnpm aisoc:demo` from a fresh clone of\n"
126
+ " https://github.com/beenuar/AiSOC\n"
127
+ )
128
+ return 0
129
+
130
+ # argparse refused to leave us here, but Pylance doesn't know that.
131
+ parser.print_help()
132
+ return 2
133
+
134
+
135
+ def _print_preamble(scenario: object) -> None:
136
+ sc = scenario # narrow for the type-checker
137
+ # We don't depend on rich so the package stays zero-dep. The
138
+ # output uses plain text + ANSI; piping to a file produces clean
139
+ # text via Ledger.render_human's TTY-aware colour code.
140
+ print(f"\nScenario: {sc.id}")
141
+ print(f"Title: {sc.title}")
142
+ if sc.narrative:
143
+ print(f"Narrative: {sc.narrative}")
144
+ if sc.mitre_techniques:
145
+ print(f"MITRE: {', '.join(sc.mitre_techniques)}")
146
+ print(f"Severity: {sc.severity}\n")
147
+
148
+
149
+ if __name__ == "__main__": # pragma: no cover — covered by smoke test.
150
+ sys.exit(main())