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.
Files changed (51) hide show
  1. aegix/__init__.py +41 -0
  2. aegix/accel/Makefile +31 -0
  3. aegix/accel/__init__.py +7 -0
  4. aegix/accel/aegix_accel.c +43 -0
  5. aegix/accel/aegix_count.S +25 -0
  6. aegix/adapters/__init__.py +1 -0
  7. aegix/adapters/base.py +57 -0
  8. aegix/adapters/generic.py +67 -0
  9. aegix/adapters/heuristic_worker.py +68 -0
  10. aegix/cli.py +198 -0
  11. aegix/core/__init__.py +1 -0
  12. aegix/core/config.py +101 -0
  13. aegix/core/events.py +38 -0
  14. aegix/core/orchestrator.py +168 -0
  15. aegix/core/reporter.py +88 -0
  16. aegix/core/store.py +217 -0
  17. aegix/core/types.py +169 -0
  18. aegix/mcp/__init__.py +1 -0
  19. aegix/mcp/engine.py +120 -0
  20. aegix/mcp/registry.py +85 -0
  21. aegix/perf/README.md +24 -0
  22. aegix/perf/__init__.py +6 -0
  23. aegix/perf/java/com/aegix/perf/DispatchCoordinator.java +80 -0
  24. aegix/platform.py +106 -0
  25. aegix/router/__init__.py +1 -0
  26. aegix/router/router.py +67 -0
  27. aegix/supervisor/__init__.py +1 -0
  28. aegix/supervisor/decomposer.py +84 -0
  29. aegix/supervisor/escalation.py +52 -0
  30. aegix/supervisor/feedback_injector.py +50 -0
  31. aegix/supervisor/loop_detector.py +88 -0
  32. aegix/supervisor/progress_scorer.py +60 -0
  33. aegix/supervisor/state.py +55 -0
  34. aegix/supervisor/supervisor.py +138 -0
  35. aegix/supervisor/token_budget.py +72 -0
  36. aegix/terminal/__init__.py +1 -0
  37. aegix/terminal/argv.py +59 -0
  38. aegix/terminal/installer.py +130 -0
  39. aegix/terminal/parser.py +240 -0
  40. aegix/terminal/pty_engine.py +92 -0
  41. aegix/terminal/simulator.py +115 -0
  42. aegix/util/__init__.py +1 -0
  43. aegix/util/accel.py +126 -0
  44. aegix/util/ansi.py +11 -0
  45. aegix/util/ids.py +60 -0
  46. aegix/util/logger.py +57 -0
  47. aegix-2.0.0.dist-info/METADATA +129 -0
  48. aegix-2.0.0.dist-info/RECORD +51 -0
  49. aegix-2.0.0.dist-info/WHEEL +4 -0
  50. aegix-2.0.0.dist-info/entry_points.txt +2 -0
  51. 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
@@ -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)")