aegix 2.0.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.
- aegix/__init__.py +41 -0
- aegix/accel/Makefile +31 -0
- aegix/accel/__init__.py +7 -0
- aegix/accel/aegix_accel.c +43 -0
- aegix/accel/aegix_count.S +25 -0
- aegix/adapters/__init__.py +1 -0
- aegix/adapters/base.py +57 -0
- aegix/adapters/generic.py +67 -0
- aegix/adapters/heuristic_worker.py +68 -0
- aegix/cli.py +198 -0
- aegix/core/__init__.py +1 -0
- aegix/core/config.py +101 -0
- aegix/core/events.py +38 -0
- aegix/core/orchestrator.py +168 -0
- aegix/core/reporter.py +88 -0
- aegix/core/store.py +217 -0
- aegix/core/types.py +169 -0
- aegix/mcp/__init__.py +1 -0
- aegix/mcp/engine.py +120 -0
- aegix/mcp/registry.py +85 -0
- aegix/perf/README.md +24 -0
- aegix/perf/__init__.py +6 -0
- aegix/perf/java/com/aegix/perf/DispatchCoordinator.java +80 -0
- aegix/platform.py +106 -0
- aegix/router/__init__.py +1 -0
- aegix/router/router.py +67 -0
- aegix/supervisor/__init__.py +1 -0
- aegix/supervisor/decomposer.py +84 -0
- aegix/supervisor/escalation.py +52 -0
- aegix/supervisor/feedback_injector.py +50 -0
- aegix/supervisor/loop_detector.py +88 -0
- aegix/supervisor/progress_scorer.py +60 -0
- aegix/supervisor/state.py +55 -0
- aegix/supervisor/supervisor.py +138 -0
- aegix/supervisor/token_budget.py +72 -0
- aegix/terminal/__init__.py +1 -0
- aegix/terminal/argv.py +59 -0
- aegix/terminal/installer.py +130 -0
- aegix/terminal/parser.py +240 -0
- aegix/terminal/pty_engine.py +92 -0
- aegix/terminal/simulator.py +115 -0
- aegix/util/__init__.py +1 -0
- aegix/util/accel.py +126 -0
- aegix/util/ansi.py +11 -0
- aegix/util/ids.py +60 -0
- aegix/util/logger.py +57 -0
- aegix-2.0.0.dist-info/METADATA +129 -0
- aegix-2.0.0.dist-info/RECORD +51 -0
- aegix-2.0.0.dist-info/WHEEL +4 -0
- aegix-2.0.0.dist-info/entry_points.txt +2 -0
- aegix-2.0.0.dist-info/licenses/LICENSE +21 -0
aegix/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Aegix — AI-Supervised Cybersecurity Tool Orchestration Platform (Python core).
|
|
2
|
+
|
|
3
|
+
The core is Python. It is supported by:
|
|
4
|
+
* TypeScript adapters (MCP / IDE clients),
|
|
5
|
+
* C and hand-tuned Assembly accelerators for hot paths (see ``aegix.accel``),
|
|
6
|
+
* a Java performance layer for high-throughput, cross-platform fan-out.
|
|
7
|
+
|
|
8
|
+
The security layer (scope, risk gating, audit) is intentionally NOT part of this
|
|
9
|
+
package. It is owned and implemented separately by the security team according to
|
|
10
|
+
their own policies and the laws of the relevant jurisdictions.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .platform import Aegix, AegixOptions
|
|
14
|
+
from .core.config import AegixConfig, load_config, DEFAULT_CONFIG
|
|
15
|
+
from .core.types import (
|
|
16
|
+
Artifact,
|
|
17
|
+
Phase,
|
|
18
|
+
ResultObject,
|
|
19
|
+
SourceClient,
|
|
20
|
+
TaskObject,
|
|
21
|
+
ToolCall,
|
|
22
|
+
ToolResult,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__version__ = "2.0.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"Aegix",
|
|
29
|
+
"AegixOptions",
|
|
30
|
+
"AegixConfig",
|
|
31
|
+
"load_config",
|
|
32
|
+
"DEFAULT_CONFIG",
|
|
33
|
+
"Artifact",
|
|
34
|
+
"Phase",
|
|
35
|
+
"ResultObject",
|
|
36
|
+
"SourceClient",
|
|
37
|
+
"TaskObject",
|
|
38
|
+
"ToolCall",
|
|
39
|
+
"ToolResult",
|
|
40
|
+
"__version__",
|
|
41
|
+
]
|
aegix/accel/Makefile
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Build the optional C/Assembly accelerator shared library.
|
|
2
|
+
#
|
|
3
|
+
# The Python core runs fine without this (pure-Python fallback). Building it
|
|
4
|
+
# unlocks the native fast paths used by sentinel.util.accel.
|
|
5
|
+
#
|
|
6
|
+
# make # build with the hand-tuned Assembly inner loop (x86-64)
|
|
7
|
+
# make portable # build portable C only (any arch)
|
|
8
|
+
# make clean
|
|
9
|
+
|
|
10
|
+
CC ?= cc
|
|
11
|
+
CFLAGS ?= -O3 -shared -fPIC
|
|
12
|
+
UNAME_S := $(shell uname -s)
|
|
13
|
+
|
|
14
|
+
ifeq ($(UNAME_S),Darwin)
|
|
15
|
+
LIB := libaegix.dylib
|
|
16
|
+
else
|
|
17
|
+
LIB := libaegix.so
|
|
18
|
+
endif
|
|
19
|
+
|
|
20
|
+
.PHONY: all portable clean
|
|
21
|
+
|
|
22
|
+
all: $(LIB)
|
|
23
|
+
|
|
24
|
+
$(LIB): aegix_accel.c aegix_count.S
|
|
25
|
+
$(CC) $(CFLAGS) -DAEGIX_ASM aegix_accel.c aegix_count.S -o $(LIB)
|
|
26
|
+
|
|
27
|
+
portable: aegix_accel.c
|
|
28
|
+
$(CC) $(CFLAGS) aegix_accel.c -o $(LIB)
|
|
29
|
+
|
|
30
|
+
clean:
|
|
31
|
+
rm -f libaegix.so libaegix.dylib aegix.dll
|
aegix/accel/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Native C/Assembly accelerator artifacts.
|
|
2
|
+
|
|
3
|
+
This package holds the source for the optional ``libaegix`` shared library
|
|
4
|
+
(C + hand-tuned Assembly). It is built via the Makefile here and loaded at
|
|
5
|
+
runtime by ``aegix.util.accel`` when present. The Python core works without
|
|
6
|
+
it via a pure-Python fallback.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* sentinel native accelerator — C hot paths for the Python core.
|
|
3
|
+
*
|
|
4
|
+
* Exposes a tiny, stable C ABI loaded by sentinel.util.accel via cffi/ctypes.
|
|
5
|
+
* The token estimator's inner counting loop is delegated to hand-tuned
|
|
6
|
+
* Assembly (see aegix_count.S) when built with AEGIX_ASM; otherwise a
|
|
7
|
+
* portable C loop is used. Either way the result matches the pure-Python
|
|
8
|
+
* heuristic (ceil(bytes / 4)) so behavior is identical, just faster.
|
|
9
|
+
*
|
|
10
|
+
* Build (Linux):
|
|
11
|
+
* cc -O3 -shared -fPIC -DAEGIX_ASM aegix_accel.c aegix_count.S -o libaegix.so
|
|
12
|
+
* Build (portable C only):
|
|
13
|
+
* cc -O3 -shared -fPIC aegix_accel.c -o libaegix.so
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
#include <stddef.h>
|
|
17
|
+
#include <stdint.h>
|
|
18
|
+
|
|
19
|
+
#ifdef AEGIX_ASM
|
|
20
|
+
/* Implemented in aegix_count.S — counts bytes via a tight SIMD-friendly loop. */
|
|
21
|
+
extern uint64_t aegix_count_bytes(const char *data, size_t len);
|
|
22
|
+
#else
|
|
23
|
+
static uint64_t aegix_count_bytes(const char *data, size_t len) {
|
|
24
|
+
(void)data;
|
|
25
|
+
return (uint64_t)len;
|
|
26
|
+
}
|
|
27
|
+
#endif
|
|
28
|
+
|
|
29
|
+
/* ceil(len / 4): conservative ~4 chars/token estimate, matching the Python path. */
|
|
30
|
+
uint64_t aegix_estimate_tokens(const char *data, size_t len) {
|
|
31
|
+
uint64_t bytes = aegix_count_bytes(data, len);
|
|
32
|
+
return (bytes + 3u) / 4u;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* Fast FNV-1a hash used by the loop detector's fuzzy signature comparison. */
|
|
36
|
+
uint64_t aegix_fnv1a(const char *data, size_t len) {
|
|
37
|
+
uint64_t h = 14695981039346656037ULL;
|
|
38
|
+
for (size_t i = 0; i < len; i++) {
|
|
39
|
+
h ^= (uint8_t)data[i];
|
|
40
|
+
h *= 1099511628211ULL;
|
|
41
|
+
}
|
|
42
|
+
return h;
|
|
43
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* aegix_count_bytes — x86-64 System V ABI hand-tuned byte counter.
|
|
3
|
+
*
|
|
4
|
+
* uint64_t aegix_count_bytes(const char *data [rdi], size_t len [rsi]);
|
|
5
|
+
*
|
|
6
|
+
* This is the Assembly inner loop backing the C accelerator's token estimator.
|
|
7
|
+
* The count itself is trivial (it returns len), but it is implemented in
|
|
8
|
+
* Assembly to demonstrate and host the native hot-path integration point: more
|
|
9
|
+
* complex SIMD scanning (e.g. UTF-8 codepoint counting, delimiter scanning) is
|
|
10
|
+
* dropped in here without touching the Python or C layers above it.
|
|
11
|
+
*
|
|
12
|
+
* Assemble as part of the shared library:
|
|
13
|
+
* cc -O3 -shared -fPIC -DAEGIX_ASM aegix_accel.c aegix_count.S -o libaegix.so
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
.text
|
|
17
|
+
.globl aegix_count_bytes
|
|
18
|
+
.type aegix_count_bytes, @function
|
|
19
|
+
aegix_count_bytes:
|
|
20
|
+
movq %rsi, %rax # rax = len (the byte count)
|
|
21
|
+
ret
|
|
22
|
+
.size aegix_count_bytes, .-aegix_count_bytes
|
|
23
|
+
|
|
24
|
+
/* Mark the stack as non-executable. */
|
|
25
|
+
.section .note.GNU-stack,"",@progbits
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Layer 1 — AI Client Adapters."""
|
aegix/adapters/base.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Layer 1 — AI Client Adapter contract.
|
|
2
|
+
|
|
3
|
+
Each supported AI client implements these protocols. Internally everything
|
|
4
|
+
speaks TaskObject / ResultObject; the adapter is the only place that knows a
|
|
5
|
+
client's native protocol (REST, JSON-RPC, MCP, VS Code API, etc.).
|
|
6
|
+
|
|
7
|
+
The ``WorkerAgent`` abstraction represents the actual AI doing the work: given
|
|
8
|
+
the current findings, it decides the next tool call (or signals done). In
|
|
9
|
+
production this is the live Claude/Gemini/etc. call; for offline runs a heuristic
|
|
10
|
+
stand-in implements the same interface.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Any, Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
from ..core.types import Artifact, Phase, ResultObject, TaskObject, ToolResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class AdapterContext:
|
|
23
|
+
phase: Phase
|
|
24
|
+
tool_whitelist: list[str]
|
|
25
|
+
findings: list[Artifact] = field(default_factory=list)
|
|
26
|
+
feedback: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class ToolCallDecision:
|
|
31
|
+
phase: Phase
|
|
32
|
+
tool: str
|
|
33
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
34
|
+
description: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class DoneDecision:
|
|
39
|
+
reason: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
AgentDecision = ToolCallDecision | DoneDecision
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@runtime_checkable
|
|
46
|
+
class WorkerAgent(Protocol):
|
|
47
|
+
async def next(self, ctx: AdapterContext) -> AgentDecision: ...
|
|
48
|
+
def observe(self, result: ToolResult) -> None: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@runtime_checkable
|
|
52
|
+
class ClientAdapter(Protocol):
|
|
53
|
+
id: str
|
|
54
|
+
|
|
55
|
+
def normalize(self, input_text: str, target: str, overrides: dict[str, Any] | None = None) -> TaskObject: ...
|
|
56
|
+
def create_worker(self, task: TaskObject) -> WorkerAgent: ...
|
|
57
|
+
def format_result(self, result: ResultObject) -> Any: ...
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Generic client adapter.
|
|
2
|
+
|
|
3
|
+
Implements the ClientAdapter contract for any client. It normalizes a goal into
|
|
4
|
+
a TaskObject and, for offline operation, builds a HeuristicWorker. A concrete
|
|
5
|
+
adapter (Claude API, Gemini CLI, MCP-based Cursor/Duo, etc.) would subclass this
|
|
6
|
+
and override ``create_worker`` to call the real model, plus ``format_result`` to
|
|
7
|
+
match the client's native response shape.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlparse
|
|
15
|
+
|
|
16
|
+
from ..core.types import Phase, ResultObject, SourceClient, TaskConstraints, TaskContext, TaskObject, Target
|
|
17
|
+
from ..util.ids import short_id, uuid
|
|
18
|
+
from .base import WorkerAgent
|
|
19
|
+
from .heuristic_worker import HeuristicWorker
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_target(raw: str) -> Target:
|
|
23
|
+
host = raw.strip()
|
|
24
|
+
if re.match(r"^https?://", host):
|
|
25
|
+
parsed = urlparse(host)
|
|
26
|
+
host = parsed.hostname or host
|
|
27
|
+
h, _, port = host.partition(":")
|
|
28
|
+
ports = [int(port)] if port.isdigit() else None
|
|
29
|
+
return Target(host=h, ports=ports)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _infer_request_type(text: str) -> Phase:
|
|
33
|
+
t = text.lower()
|
|
34
|
+
if re.search(r"reverse|binary|\bexe\b|ghidra|decompile", t):
|
|
35
|
+
return "analysis"
|
|
36
|
+
if re.search(r"exploit|shell|access", t):
|
|
37
|
+
return "exploit"
|
|
38
|
+
if re.search(r"enumerat", t):
|
|
39
|
+
return "enumeration"
|
|
40
|
+
if re.search(r"scan|vulnerab|secure|owasp", t):
|
|
41
|
+
return "vuln_scan"
|
|
42
|
+
return "recon"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GenericAdapter:
|
|
46
|
+
def __init__(self, client: SourceClient, simulate_loop_on: str | None = None) -> None:
|
|
47
|
+
self.id = client
|
|
48
|
+
self._client = client
|
|
49
|
+
self._simulate_loop_on = simulate_loop_on
|
|
50
|
+
|
|
51
|
+
def normalize(self, input_text: str, target: str, overrides: dict[str, Any] | None = None) -> TaskObject:
|
|
52
|
+
overrides = overrides or {}
|
|
53
|
+
return TaskObject(
|
|
54
|
+
task_id=uuid(),
|
|
55
|
+
source_client=self._client,
|
|
56
|
+
request_type=overrides.get("request_type", _infer_request_type(input_text)),
|
|
57
|
+
natural_input=input_text,
|
|
58
|
+
target=_parse_target(target),
|
|
59
|
+
context=TaskContext(session_id=short_id("sess")),
|
|
60
|
+
constraints=overrides.get("constraints", TaskConstraints()),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def create_worker(self, task: TaskObject) -> WorkerAgent:
|
|
64
|
+
return HeuristicWorker(task.target, self._simulate_loop_on)
|
|
65
|
+
|
|
66
|
+
def format_result(self, result: ResultObject) -> Any:
|
|
67
|
+
return result.to_native()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Heuristic worker agent.
|
|
2
|
+
|
|
3
|
+
A deterministic stand-in for a live AI client used for offline runs, demos and
|
|
4
|
+
tests. It deliberately exhibits the failure modes the Supervisor exists to catch
|
|
5
|
+
(repeating a scan, low-productivity tangents) so the supervision loop is
|
|
6
|
+
observable end-to-end. A real adapter swaps this for live model calls behind the
|
|
7
|
+
same WorkerAgent protocol.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from ..core.types import Target, ToolResult
|
|
15
|
+
from .base import AdapterContext, AgentDecision, DoneDecision, ToolCallDecision
|
|
16
|
+
|
|
17
|
+
_PHASE_PRIMARY: dict[str, str] = {
|
|
18
|
+
"recon": "nmap",
|
|
19
|
+
"enumeration": "gobuster",
|
|
20
|
+
"vuln_scan": "nuclei",
|
|
21
|
+
"exploit": "metasploit",
|
|
22
|
+
"analysis": "ghidra",
|
|
23
|
+
"post_exploit": "volatility",
|
|
24
|
+
"report": "",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HeuristicWorker:
|
|
29
|
+
def __init__(self, target: Target, simulate_loop_on: str | None = None) -> None:
|
|
30
|
+
self._target = target
|
|
31
|
+
self._simulate_loop_on = simulate_loop_on
|
|
32
|
+
self._calls_this_phase = 0
|
|
33
|
+
self._last_phase = ""
|
|
34
|
+
self._redirected = False
|
|
35
|
+
|
|
36
|
+
async def next(self, ctx: AdapterContext) -> AgentDecision:
|
|
37
|
+
if ctx.phase != self._last_phase:
|
|
38
|
+
self._calls_this_phase = 0
|
|
39
|
+
self._redirected = False
|
|
40
|
+
self._last_phase = ctx.phase
|
|
41
|
+
|
|
42
|
+
if ctx.feedback and re.search(r"LOOP_DETECTED|LOW_PRODUCTIVITY", ctx.feedback):
|
|
43
|
+
self._redirected = True
|
|
44
|
+
|
|
45
|
+
primary = _PHASE_PRIMARY.get(ctx.phase, "")
|
|
46
|
+
if not primary or ctx.phase == "report":
|
|
47
|
+
return DoneDecision(reason=f"phase '{ctx.phase}' produces a report, no tools to call")
|
|
48
|
+
|
|
49
|
+
self._calls_this_phase += 1
|
|
50
|
+
|
|
51
|
+
# Demonstrate a loop until the supervisor redirects.
|
|
52
|
+
if self._simulate_loop_on == primary and not self._redirected and self._calls_this_phase <= 6:
|
|
53
|
+
return ToolCallDecision(
|
|
54
|
+
phase=ctx.phase, tool=primary, params={"host": self._target.host},
|
|
55
|
+
description=f"{ctx.phase} scan with {primary}",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if self._calls_this_phase <= 2 and not self._redirected:
|
|
59
|
+
return ToolCallDecision(
|
|
60
|
+
phase=ctx.phase, tool=primary, params={"host": self._target.host},
|
|
61
|
+
description=f"{ctx.phase} scan with {primary}",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
return DoneDecision(reason=f"phase '{ctx.phase}' complete")
|
|
65
|
+
|
|
66
|
+
def observe(self, _result: ToolResult) -> None:
|
|
67
|
+
# A live agent would update its reasoning here.
|
|
68
|
+
return None
|
aegix/cli.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Aegix CLI — reference host application.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
aegix run "<goal>" --target <host> [--dry-run] [--client <id>]
|
|
5
|
+
[--yes] [--demo-loop <tool>] [--verbose]
|
|
6
|
+
aegix registry
|
|
7
|
+
aegix accel Show native accelerator / Java performance layer status
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
import json as _json
|
|
17
|
+
|
|
18
|
+
from .core.config import load_config
|
|
19
|
+
from .core.events import bus
|
|
20
|
+
from .core.store import SessionStore
|
|
21
|
+
from .mcp.registry import McpRegistry
|
|
22
|
+
from .platform import Aegix, AegixOptions
|
|
23
|
+
from .supervisor.escalation import EscalationRequest
|
|
24
|
+
from .util.accel import accel
|
|
25
|
+
from .util.logger import set_log_level
|
|
26
|
+
|
|
27
|
+
_DEFAULT_DB = ".aegix/sessions.db"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _wire_live_log(stream=sys.stdout) -> None:
|
|
31
|
+
"""Stream live progress events.
|
|
32
|
+
|
|
33
|
+
When the caller requests machine-readable output (``--json``), the live log
|
|
34
|
+
is routed to ``stderr`` so that ``stdout`` carries only the JSON document and
|
|
35
|
+
stays parseable by downstream consumers.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def out(msg: str) -> None:
|
|
39
|
+
print(msg, file=stream)
|
|
40
|
+
|
|
41
|
+
bus.on("phase:enter", lambda e: out(f"\n=== PHASE: {e['phase'].upper()} ==="))
|
|
42
|
+
bus.on("tool:call", lambda c: out(f" → {c.tool} {c.params}"))
|
|
43
|
+
bus.on("artifact:new", lambda e: out(f" + {e['artifact'].kind}: {e['artifact'].value}"))
|
|
44
|
+
bus.on("supervisor:feedback", lambda f: out(f" ⚠ SUPERVISOR [{f['reason']}]: {f['message']}"))
|
|
45
|
+
bus.on("supervisor:escalate", lambda e: out(f" ⛔ ESCALATION [{e['reason']}]: {e['summary']}"))
|
|
46
|
+
|
|
47
|
+
def _budget(b: dict) -> None:
|
|
48
|
+
if b["level"] not in ("monitor", "watch"):
|
|
49
|
+
out(f" $ budget {b['level']}: {b['tokensUsed']}/{b['tokensBudget']} tokens")
|
|
50
|
+
|
|
51
|
+
bus.on("budget:level", _budget)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _prompt(question: str) -> bool:
|
|
55
|
+
try:
|
|
56
|
+
return input(question).strip().lower() in ("y", "yes")
|
|
57
|
+
except EOFError:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _cmd_run(args: argparse.Namespace) -> None:
|
|
62
|
+
if not args.goal:
|
|
63
|
+
print('error: provide a goal, e.g. aegix run "check if my web app is secure" --target example.test')
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
if args.verbose:
|
|
66
|
+
set_log_level("debug")
|
|
67
|
+
# In --json mode, keep stdout clean for the JSON document: send all
|
|
68
|
+
# human-readable progress to stderr instead.
|
|
69
|
+
log_stream = sys.stderr if args.json else sys.stdout
|
|
70
|
+
_wire_live_log(log_stream)
|
|
71
|
+
|
|
72
|
+
auto_yes = args.yes
|
|
73
|
+
|
|
74
|
+
async def confirm_mcp(prompt: str) -> bool:
|
|
75
|
+
return True if auto_yes else _prompt(prompt)
|
|
76
|
+
|
|
77
|
+
async def human_gate(req: EscalationRequest):
|
|
78
|
+
print(f"\n ⛔ HUMAN GATE [{req.reason}]: {req.summary}")
|
|
79
|
+
if auto_yes:
|
|
80
|
+
return "deny"
|
|
81
|
+
return "approve" if _prompt(" Approve? [y/N] ") else "deny"
|
|
82
|
+
|
|
83
|
+
store = None if args.no_store else SessionStore(args.db)
|
|
84
|
+
|
|
85
|
+
app = Aegix(
|
|
86
|
+
AegixOptions(
|
|
87
|
+
client=args.client,
|
|
88
|
+
dry_run=args.dry_run,
|
|
89
|
+
simulate_loop_on=args.demo_loop,
|
|
90
|
+
confirm_mcp=confirm_mcp,
|
|
91
|
+
human_gate=human_gate,
|
|
92
|
+
store=store,
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
mode = "DRY-RUN (simulated)" if args.dry_run else "LIVE"
|
|
97
|
+
banner_stream = sys.stderr if args.json else sys.stdout
|
|
98
|
+
print(f'\nGoal: "{args.goal}"\nTarget: {args.target}\nMode: {mode}', file=banner_stream)
|
|
99
|
+
result = await app.run(args.goal, args.target)
|
|
100
|
+
|
|
101
|
+
if args.json:
|
|
102
|
+
print(_json.dumps(result.to_native(), indent=2))
|
|
103
|
+
else:
|
|
104
|
+
print("\n" + "=" * 60 + f"\nRESULT ({result.status})\n" + "=" * 60)
|
|
105
|
+
print(result.summary + "\n")
|
|
106
|
+
print(f"Tokens used: {result.tokens_used} / {result.tokens_budget}")
|
|
107
|
+
print(f"Phases: {' → '.join(result.phases_completed)}")
|
|
108
|
+
if store is not None:
|
|
109
|
+
print(f"\nSession saved to {args.db} (task {result.task_id[:8]}…)")
|
|
110
|
+
|
|
111
|
+
if store is not None:
|
|
112
|
+
store.close()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _cmd_history(args: argparse.Namespace) -> None:
|
|
116
|
+
store = SessionStore(args.db)
|
|
117
|
+
runs = store.recent_runs(args.limit)
|
|
118
|
+
store.close()
|
|
119
|
+
if not runs:
|
|
120
|
+
print("No runs recorded yet.")
|
|
121
|
+
return
|
|
122
|
+
print(f"Recent runs ({len(runs)}):")
|
|
123
|
+
for r in runs:
|
|
124
|
+
print(
|
|
125
|
+
f" {r['task_id'][:8]} {r['status']:9} risk={r['risk_label']:8} "
|
|
126
|
+
f"({r['risk_score']:>3}/100) {r['client']:10} {r['goal'][:42]}"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _cmd_export(args: argparse.Namespace) -> None:
|
|
131
|
+
store = SessionStore(args.db)
|
|
132
|
+
if args.task_id:
|
|
133
|
+
print(_json.dumps(store.run_detail(args.task_id), indent=2, default=str))
|
|
134
|
+
else:
|
|
135
|
+
print(store.to_json())
|
|
136
|
+
store.close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _cmd_registry(_args: argparse.Namespace) -> None:
|
|
140
|
+
cfg = load_config()
|
|
141
|
+
reg = McpRegistry.load(cfg.registry_path)
|
|
142
|
+
print("Registered MCP tools:")
|
|
143
|
+
for name in reg.list():
|
|
144
|
+
e = reg.get(name)
|
|
145
|
+
assert e is not None
|
|
146
|
+
print(f" - {name} [{e.trust_tier}] {e.transport} {e.mcp_server}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _cmd_accel(_args: argparse.Namespace) -> None:
|
|
150
|
+
print("Native acceleration status:")
|
|
151
|
+
print(f" C/Assembly fast path : {'ENABLED' if accel.has_native else 'not present (pure-Python fallback)'}")
|
|
152
|
+
print(f" Java perf layer : {'available' if accel.jvm_available() else 'not present (asyncio fallback)'}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def main(argv: list[str] | None = None) -> None:
|
|
156
|
+
parser = argparse.ArgumentParser(prog="aegix", description="AI-Supervised Cybersecurity Tool Orchestration")
|
|
157
|
+
sub = parser.add_subparsers(dest="command")
|
|
158
|
+
|
|
159
|
+
run = sub.add_parser("run", help="Run a supervised assessment")
|
|
160
|
+
run.add_argument("goal", nargs="?")
|
|
161
|
+
run.add_argument("--target", default="scanme.nmap.org")
|
|
162
|
+
run.add_argument("--dry-run", action="store_true")
|
|
163
|
+
run.add_argument("--client", default="custom")
|
|
164
|
+
run.add_argument("--yes", "-y", action="store_true")
|
|
165
|
+
run.add_argument("--demo-loop", default=None)
|
|
166
|
+
run.add_argument("--verbose", "-v", action="store_true")
|
|
167
|
+
run.add_argument("--json", action="store_true", help="Print the result as JSON")
|
|
168
|
+
run.add_argument("--db", default=_DEFAULT_DB, help="Session store path")
|
|
169
|
+
run.add_argument("--no-store", action="store_true", help="Do not persist this run")
|
|
170
|
+
|
|
171
|
+
sub.add_parser("registry", help="List registered MCP tools")
|
|
172
|
+
sub.add_parser("accel", help="Show native accelerator / Java performance layer status")
|
|
173
|
+
|
|
174
|
+
hist = sub.add_parser("history", help="Show recent run history")
|
|
175
|
+
hist.add_argument("--db", default=_DEFAULT_DB)
|
|
176
|
+
hist.add_argument("--limit", type=int, default=20)
|
|
177
|
+
|
|
178
|
+
exp = sub.add_parser("export", help="Export run history (or one run) as JSON")
|
|
179
|
+
exp.add_argument("task_id", nargs="?", default=None)
|
|
180
|
+
exp.add_argument("--db", default=_DEFAULT_DB)
|
|
181
|
+
|
|
182
|
+
args = parser.parse_args(argv)
|
|
183
|
+
if args.command == "run":
|
|
184
|
+
asyncio.run(_cmd_run(args))
|
|
185
|
+
elif args.command == "registry":
|
|
186
|
+
_cmd_registry(args)
|
|
187
|
+
elif args.command == "accel":
|
|
188
|
+
_cmd_accel(args)
|
|
189
|
+
elif args.command == "history":
|
|
190
|
+
_cmd_history(args)
|
|
191
|
+
elif args.command == "export":
|
|
192
|
+
_cmd_export(args)
|
|
193
|
+
else:
|
|
194
|
+
parser.print_help()
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
main()
|
aegix/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core domain: shared types, config, event bus, reporter."""
|
aegix/core/config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Configuration loading + validation.
|
|
2
|
+
|
|
3
|
+
User settings come from ``config.yaml`` (human-readable), validated against sane
|
|
4
|
+
defaults so a missing or partial file never crashes the platform.
|
|
5
|
+
|
|
6
|
+
The security layer (scope, risk gating, audit) is configured separately by the
|
|
7
|
+
security team and is intentionally absent here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from ..util.logger import get_logger
|
|
18
|
+
|
|
19
|
+
_log = get_logger("config")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class SupervisorConfig:
|
|
24
|
+
loop_threshold: int = 3
|
|
25
|
+
productivity_window: int = 5
|
|
26
|
+
loop_escalation_limit: int = 3
|
|
27
|
+
token_safety_margin: float = 0.15
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class AegixConfig:
|
|
32
|
+
default_max_tokens: int = 200_000
|
|
33
|
+
phase_budget: dict[str, float] = field(
|
|
34
|
+
default_factory=lambda: {
|
|
35
|
+
"recon": 0.15,
|
|
36
|
+
"enumeration": 0.20,
|
|
37
|
+
"vuln_scan": 0.25,
|
|
38
|
+
"exploit": 0.20,
|
|
39
|
+
"analysis": 0.05,
|
|
40
|
+
"post_exploit": 0.05,
|
|
41
|
+
"report": 0.10,
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
supervisor: SupervisorConfig = field(default_factory=SupervisorConfig)
|
|
45
|
+
dry_run: bool = False
|
|
46
|
+
registry_path: str = "config/registry.json"
|
|
47
|
+
# When True, independent sub-tasks within a phase may dispatch in parallel
|
|
48
|
+
# (offloaded to the Java performance layer when available).
|
|
49
|
+
parallel_dispatch: bool = True
|
|
50
|
+
max_parallelism: int = 8
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DEFAULT_CONFIG = AegixConfig()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _coerce(base: AegixConfig, raw: dict[str, Any]) -> AegixConfig:
|
|
57
|
+
sup = base.supervisor
|
|
58
|
+
raw_sup = raw.get("supervisor") or {}
|
|
59
|
+
sup = replace(
|
|
60
|
+
sup,
|
|
61
|
+
loop_threshold=int(raw_sup.get("loopThreshold", sup.loop_threshold)),
|
|
62
|
+
productivity_window=int(raw_sup.get("productivityWindow", sup.productivity_window)),
|
|
63
|
+
loop_escalation_limit=int(raw_sup.get("loopEscalationLimit", sup.loop_escalation_limit)),
|
|
64
|
+
token_safety_margin=float(raw_sup.get("tokenSafetyMargin", sup.token_safety_margin)),
|
|
65
|
+
)
|
|
66
|
+
return replace(
|
|
67
|
+
base,
|
|
68
|
+
default_max_tokens=int(raw.get("defaultMaxTokens", base.default_max_tokens)),
|
|
69
|
+
phase_budget={**base.phase_budget, **(raw.get("phaseBudget") or {})},
|
|
70
|
+
supervisor=sup,
|
|
71
|
+
dry_run=bool(raw.get("dryRun", base.dry_run)),
|
|
72
|
+
registry_path=str(raw.get("registryPath", base.registry_path)),
|
|
73
|
+
parallel_dispatch=bool(raw.get("parallelDispatch", base.parallel_dispatch)),
|
|
74
|
+
max_parallelism=int(raw.get("maxParallelism", base.max_parallelism)),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_config(path: str = "config/config.yaml") -> AegixConfig:
|
|
79
|
+
abs_path = Path(os.getcwd()) / path
|
|
80
|
+
if not abs_path.exists():
|
|
81
|
+
_log.warning("config not found at %s, using defaults", path)
|
|
82
|
+
return DEFAULT_CONFIG
|
|
83
|
+
try:
|
|
84
|
+
import yaml # local import so the core imports without PyYAML for typing
|
|
85
|
+
|
|
86
|
+
raw = yaml.safe_load(abs_path.read_text("utf-8")) or {}
|
|
87
|
+
cfg = _coerce(DEFAULT_CONFIG, raw)
|
|
88
|
+
_validate(cfg)
|
|
89
|
+
_log.info("loaded config from %s", path)
|
|
90
|
+
return cfg
|
|
91
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
92
|
+
_log.error("failed to parse config, using defaults: %s", exc)
|
|
93
|
+
return DEFAULT_CONFIG
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _validate(cfg: AegixConfig) -> None:
|
|
97
|
+
total = sum(cfg.phase_budget.values())
|
|
98
|
+
if abs(total - 1.0) > 0.01:
|
|
99
|
+
_log.warning("phaseBudget fractions sum to %.2f, expected ~1.0", total)
|
|
100
|
+
if not 0.0 <= cfg.supervisor.token_safety_margin < 1.0:
|
|
101
|
+
raise ValueError("supervisor.tokenSafetyMargin must be in [0, 1)")
|