Skip to content

AEGIS bounded-autonomy upgrade — egress → CROSS-3 seal (squashed release)#9

Merged
gesh75 merged 1 commit into
mainfrom
release/aegis-bounded-autonomy
Jun 1, 2026
Merged

AEGIS bounded-autonomy upgrade — egress → CROSS-3 seal (squashed release)#9
gesh75 merged 1 commit into
mainfrom
release/aegis-bounded-autonomy

Conversation

@gesh75

@gesh75 gesh75 commented Jun 1, 2026

Copy link
Copy Markdown
Owner

AEGIS bounded-autonomy upgrade — the whole arc, squashed for review

One commit that collapses the 8-PR stack (#2#8) onto current main, so the entire feature reviews as a single diff (30 files, +3.4k). The offline, hardware-rootable bounded-autonomy receipt and everything under it. AUTO ships disabled by default.

What it delivers

Layer Module What
Egress core/llm/ single shared LLM egress + adapter — local-first fallback, retry/cooldown, 4-event hook bus; raw httpx (no anthropic SDK); air-gap fail-closed
Wedge (#4) evidence/ change.model_identity sealed into the bundle hash; honest UNKNOWN / unattested states, never a faked attribution
Authority (#5-core) core/risk/ deterministic AUTO/HITL/HOTL/BLOCK model — severity ≠ authority; AS/RD/RT → BLOCK; spine/underlay → ≥ HOTL
Ceiling (#5-ceiling) core/promote/ gate rule G5 enforces the ceiling from the sealed required tier; change.authority sealed (bundle 1.1→1.2)
Seal (CROSS-3) core/seal/ detached, offline-verifiable receipt (Ed25519; gates G0–G6; refuses to seal an unbounded change)
Live wiring serve.py, evidence/pdf.py /api/preflight/run emits the seal · /api/seal/{pubkey,verify} · examiner PDF shows it
Plan docs/PIV_HARDWARE_SIGNER_PLAN.md the final hardware-gated pass

Tests — full matrix green (verified on this branch, base + flask/reportlab venv)

api 23 checks / 7 routes · pdf · seal 15 · stress 2000 · promote · ceiling 8 · authority 14 · twin · contract 14 · wedge 10 · egress 29 — all PASS. Property-tested invariants: no-self-escalation 0-violations, every-seal-is-bounded, tamper detection, offline-keyless verify, air-gap fail-closed.

Notes

Try it: python -m aegis.servePOST /api/preflight/run {"intent":"add vlan 10 to leaf-1"} returns a bundle with a seal; POST /api/seal/verify confirms it offline.

Summary by Sourcery

Introduce a shared, air-gapped LLM egress layer, deterministic authority/ceiling model, and a detached, offline-verifiable seal that binds model identity and bounded autonomy into evidence bundles, exposing seal APIs and wiring them through the preflight pipeline and PDF evidence output.

New Features:

  • Add a single LLM egress and adapter with local-first and optional Anthropic cloud backends, retry/cooldown, hook bus, and model identity resolution for both local weights and cloud models.
  • Embed model identity and a computed authority record into evidence bundles, including support for operator-supplied and unattested identities.
  • Implement a CROSS-3 seal subsystem that produces detached, offline-verifiable receipts over bundles using Ed25519 signatures, plus public verification and seal APIs in the HTTP service.
  • Expose the seal and model/authority information through the preflight API response and evidence PDF output, and publish the server’s verification public key for offline validation.
  • Add a documented plan for upgrading the software Ed25519 seal signer to a hardware-backed PIV signer and binding the authority ceiling to hardware-attested policy.

Enhancements:

  • Tighten air-gap guarantees around LLM usage by enforcing loopback-only local endpoints, forbidding cloud backends and Anthropic SDK usage in air-gapped mode, and centralizing all LLM calls through the new egress module.
  • Extend promotion gating with a G5 no-self-escalation rule that enforces the sealed authority ceiling at promotion time and fails closed when authority data is missing or malformed.
  • Update evidence bundle construction to version 1.2, sealing change authority and model identity under the integrity hash while excluding detached seals from the hash input.
  • Refine the simulator backend to expose a deterministic model identity for reproducible stress testing and bundle sealing.
  • Enhance the API server to manage a process-wide Ed25519 signing key (configurable via environment) and to support offline seal verification without storing private key material in verification flows.

Build:

  • Add the cryptography library to dependencies to support Ed25519 and future hardware-backed signing implementations.

Documentation:

  • Add a PIV hardware signer plan describing how to migrate the seal signer to a YubiKey-backed ECDSA key and bind the authority ceiling to a hardware-rooted policy.

Tests:

  • Add comprehensive test suites for LLM egress behavior (taxonomy, retry, air-gap invariants, hooks, HTTP backend), model identity wedge behavior, authority model and ceiling enforcement, seal generation/verification (including G0–G6 gate behavior and properties), and extended API/promotion behavior around seals and authority.

…authority gate, ceiling, CROSS-3 seal

Squashed release of the 8-PR stack (#2..#8): the offline, hardware-rootable bounded-autonomy
receipt and everything under it. AUTO disabled by default; the only un-built piece is the
hardware-PIV signer (docs/PIV_HARDWARE_SIGNER_PLAN.md) — software Ed25519 runs the whole demo.

- core/llm: single shared LLM egress + adapter (local-first fallback chain, retry/cooldown,
  4-event hook bus; raw httpx, no anthropic SDK; air-gap fail-closed). [#2]
- evidence: model-identity wedge — change.model_identity sealed into the bundle hash; honest
  UNKNOWN / unattested states, never a faked attribution. [#3]
- core/risk: deterministic AUTO/HITL/HOTL/BLOCK authority model (severity != authority;
  AS/RD/RT -> BLOCK; spine/underlay -> >= HOTL) + the no-self-escalation ceiling. [#4]
- core/promote: gate rule G5 enforces the ceiling from the sealed required tier;
  change.authority sealed (bundle_version 1.1->1.2). [#5]
- core/seal: CROSS-3 detached, offline-verifiable bounded-autonomy receipt (Ed25519; gates
  G0..G6; refuses to seal an unbounded change). [#6]
- serve: /api/preflight/run emits the seal; GET /api/seal/pubkey + POST /api/seal/verify;
  examiner PDF shows the seal. [#7]
- docs: PIV hardware-signer plan — the final, hardware-gated CROSS-3 pass. [#8]

Property-tested safety invariants: no-self-escalation 0-violations, every-seal-is-bounded,
tamper detection, offline-keyless verify, air-gap fail-closed.
@sourcery-ai

sourcery-ai Bot commented Jun 1, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduces a shared, policy-aware LLM egress layer, deterministic authority model, and a detached Ed25519-based CROSS-3 seal that produces offline-verifiable bounded-autonomy receipts, then wires them through the preflight pipeline, promotion gate, API, simulator backend, PDF evidence, and tests, updating the evidence bundle schema/version accordingly.

Sequence diagram for preflight run with bounded-autonomy seal emission

sequenceDiagram
    actor Operator
    participant FlaskApp as serve.preflight_run
    participant Pipeline as run_preflight
    participant Backend as SimulatorBackend
    participant Authority as authority_record
    participant Bundler as build_bundle
    participant Signer as Ed25519Signer

    Operator->>FlaskApp: POST /api/preflight/run
    FlaskApp->>Pipeline: run_preflight(intent, backend, lab, operator,...)
    Pipeline->>Backend: generate_config
    Backend-->>Pipeline: configs
    Pipeline->>Backend: model_identity
    Backend-->>Pipeline: model_identity dict
    Pipeline->>Authority: authority_record(unify_severity(...), classify_change(configs), load_max_authorized())
    Authority-->>Pipeline: authority dict
    Pipeline->>Bundler: build_bundle(..., model_identity, authority)
    Bundler-->>Pipeline: bundle
    Pipeline-->>FlaskApp: bundle
    FlaskApp->>Signer: seal_response(bundle, _SIGNER, now)
    Signer-->>FlaskApp: seal or error_reason
    FlaskApp->>FlaskApp: bundle["seal"] = seal
    FlaskApp-->>Operator: 200 {bundle with detached seal}

    Operator->>FlaskApp: POST /api/seal/verify {bundle, seal}
    FlaskApp->>FlaskApp: verify_seal(bundle, seal, _VERIFIER)
    FlaskApp-->>Operator: {valid, gate, reason}
Loading

File-Level Changes

Change Details Files
Bundle schema extended to seal model identity and authority, with fail-closed defaults and seal excluded from integrity hash.
  • Bumped bundle_version to 1.2 and added change.model_identity and change.authority fields with deterministic defaults.
  • Ensured compute_sha256 omits any embedded seal field so receipts stay detached from bundle integrity.
  • Added helpers for unknown/unattested model identities and a fail-closed authority record when pipeline does not supply one.
evidence/bundler.py
evidence/schema/evidence_bundle.schema.json
Preflight pipeline now computes and seals model identity and authority records using a new risk/authority module.
  • Extended run_preflight to accept an optional model_identity and to derive identity from backend.model_identity or unattested_identity when missing.
  • Introduced authority computation via unify_severity, classify_change, and authority_record, then passed model_identity and authority into build_bundle.
  • Ensured config_import uses the honest operator-supplied unknown identity while nl_intent paths are always attributed or explicitly marked unattested.
core/orchestrator/pipeline.py
core/risk/authority.py
core/risk/__init__.py
tests/authority_test.py
tests/ceiling_test.py
Promotion gate enforces a sealed autonomy ceiling (G5) using the required authority versus configured maximum, preventing self-escalation.
  • Added G5 logic in promote gate to read sealed change.authority, re-interpret required tier against load_max_authorized, and block when above ceiling.
  • Made gate fail-closed when authority is missing or invalid, with clear denial reasons.
  • Property-tested that no promoted bundle exceeds its configured ceiling and that env-based ceiling configuration fails closed on invalid values.
core/promote/gate.py
core/risk/authority.py
tests/ceiling_test.py
Implemented CROSS-3 seal: detached, offline-verifiable receipts over bundle hash, model identity, and authority using Ed25519 signatures.
  • Defined Signer/Verifier protocols and Ed25519Signer/Verifier with key_id derivation for pinning, plus canonical claim serialization.
  • Implemented seal_bundle, verify_seal, seal_response, and SealVerdict with gates G0–G6 covering structure, key pinning, signature, bundle binding, model identity, authority bounds, and bundle integrity.
  • Added comprehensive tests for round-trip signing, tamper detection, unbounded-change refusal, schema conformance, and the invariant that every valid seal certifies a bounded change.
core/seal/signing.py
core/seal/seal.py
core/seal/__init__.py
core/seal/schema/seal.schema.json
tests/seal_test.py
API server now issues seals on preflight runs and exposes endpoints for publishing the public key and offline seal verification.
  • Loaded an Ed25519 signing key at startup from AEGIS_SEAL_KEY (or generated ephemeral), with a matching verifier instance.
  • Augmented /api/preflight/run to attach a seal (or None) to the bundle using seal_response, keeping the seal detached from integrity.
  • Added /api/seal/pubkey to expose algorithm, key_id, and public key, and /api/seal/verify to validate {bundle,seal} pairs using verify_seal, returning structured verdicts.
serve.py
tests/api_test.py
Evidence PDF now renders bounded-autonomy seal details when present.
  • Extended PDF rendering to include a bounded-autonomy seal section summarizing model provider/model, authority bounds (required vs ceiling and within/exceeds), and signature algorithm/key id.
  • Ensured layout integrates with existing integrity section without affecting bundles lacking seals.
evidence/pdf.py
Simulator backend and stress harness now expose deterministic model identity for sealing and tests.
  • Added simulator.model_identity method returning a fixed, reproducible ModelIdentity with deterministic resolved_at_utc.
  • Updated stress-test wrapper backend to proxy model_identity so stress invariants continue to hold.
core/backends/simulator.py
tests/stress_test.py
Introduced a shared LLM egress layer (LLMEgress) with backends, error taxonomy, air-gap enforcement, and model-identity adapter, plus extensive tests.
  • Added LLMEgress, HookBus, and LLMRequest to implement a single egress with ordered fallback, retry/backoff, cooldown, and hook events for request/response/result.
  • Implemented OpenAI-compatible local and Anthropic cloud backends using raw httpx, with AdapterError taxonomy, classify/is_retryable, and airgap checks preventing cloud or non-loopback egress when AEGIS_AIRGAP=1.
  • Added model identity resolution with weights-sha256 hashing for local models, identity-claim for cloud, and an IdentitySink for capturing identities through hooks, alongside single-egress and air-gap property tests (no anthropic imports outside core/llm, no litellm, loopback parsing hardened).
core/llm/__init__.py
core/llm/egress.py
core/llm/backends.py
core/llm/errors.py
core/llm/config.py
core/llm/identity.py
core/llm/airgap.py
core/llm/adapter.py
tests/llm_egress_test.py
Wedge tests validate model-identity sealing semantics across nl_intent, config_import, and unattested generators, plus end-to-end pipeline wiring.
  • Verified that nl_intent preflight bundles at v1.2 carry simulator model_identity and that config_import uses the honest operator-supplied unknown identity.
  • Tested explicit model_identity passthrough, cloud identity-claim semantics (no fake weight hashes), and that tampering with sealed model_identity breaks integrity.
  • Exercised the IdentitySink + egress.after_response path to feed model_identity into run_preflight and ensured nl_intent without backend attestation is marked 'unattested', not operator-supplied.
tests/wedge_test.py
Documented the future hardware PIV signer plan for upgrading the software Ed25519 seal to a hardware-rooted signer and binding the authority ceiling to hardware.
  • Added a detailed design doc outlining a PIV-based signer implementing the existing Signer protocol, attestation verification against the Yubico PIV root, and how to bind max_authorized ceiling to a PIV-signed policy.
  • Clarified threat model, rotation procedures, dependencies, and test strategy (hardware-free and hardware-gated suites).
docs/PIV_HARDWARE_SIGNER_PLAN.md
Updated dependencies to support cryptographic sealing and JSON-schema validation of seals.
  • Added cryptography>=42 to requirements to support Ed25519 keys and signatures for the seal implementation.
  • Ensured jsonschema-based validations for bundles and seals are optional/self-skipping when jsonschema is missing.
requirements.txt
tests/seal_test.py
tests/wedge_test.py
tests/ceiling_test.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • In core/llm/airgap.is_loopback you currently treat any *.local hostname as in-perimeter; that can still resolve to a different machine on the LAN and undermines the air-gap guarantee, so consider restricting to literal loopback IPs and localhost only (no .local suffix wildcard).
  • There are now two slightly different "unknown identity" helpers (evidence.bundler._unknown_identity and core.llm.identity.unknown_identity plus unattested_identity), which makes the semantics easy to drift; consider consolidating them into a single well-documented helper to keep the identity states consistent across the pipeline.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `core/llm/airgap.is_loopback` you currently treat any `*.local` hostname as in-perimeter; that can still resolve to a different machine on the LAN and undermines the air-gap guarantee, so consider restricting to literal loopback IPs and `localhost` only (no `.local` suffix wildcard).
- There are now two slightly different "unknown identity" helpers (`evidence.bundler._unknown_identity` and `core.llm.identity.unknown_identity` plus `unattested_identity`), which makes the semantics easy to drift; consider consolidating them into a single well-documented helper to keep the identity states consistent across the pipeline.

## Individual Comments

### Comment 1
<location path="core/llm/airgap.py" line_range="48-53" />
<code_context>
+        return False  # reject empty / relative — never auto-allow
+    if host in _LOOPBACK_NAMES:
+        return True
+    try:
+        return ip_address(host).is_loopback  # 127.0.0.0/8 and ::1, strictly
+    except ValueError:
+        # Not a literal IP. Only the reserved, non-internet-routable mDNS suffix is
+        # treated as in-perimeter (RFC 6762); everything else is non-loopback.
+        return host.endswith(".local")
+
+
</code_context>
<issue_to_address>
**🚨 question (security):** Allowing arbitrary `*.local` hosts in air-gap mode may be looser than intended for a strict loopback-only policy.

The `is_loopback` docstring promises “genuine loopback” or the literal `localhost` / reserved `.local` mDNS host, but this implementation lets any `.local` name through. That allows `AEGIS_LLM_URL` to point at a non-loopback LAN host and still satisfy the air-gap check. To keep air-gap mode strictly loopback-only, consider allowing only literal loopback IPs plus explicit aliases like `localhost` / `ip6-localhost`, and removing the generic `.local` suffix handling so it matches the `assert_airgap_ok` "non-loopback egress forbidden" invariant.
</issue_to_address>

### Comment 2
<location path="core/llm/egress.py" line_range="48" />
<code_context>
+    hints: Mapping[str, object] = field(default_factory=dict)
+
+
+class HookBus:
+    """Deterministic, in-process, synchronous hook dispatch — the ENTIRE extension surface.
+
</code_context>
<issue_to_address>
**issue (complexity):** Consider tightening the HookBus API around explicit transform/observe events and simplifying the sync bridge implementation to reduce cognitive and threading complexity without changing behavior.

You can keep all current features but trim some complexity in two focused spots: the hook bus API and the sync bridge.

---

### 1. Make `HookBus` event types explicit

Right now `HookBus` treats all events uniformly as `HookEvent`, even though only some are transforming and some are observing. Splitting these at the type/API level keeps the extension surface but makes control flow easier to follow and reduces misuse surface.

```python
# narrow the event types
TransformEvent = Literal["before_request", "build_payload"]
ObserveEvent = Literal["after_response", "on_result"]

class HookBus:
    def __init__(self) -> None:
        self._transform_hooks: dict[TransformEvent, list[Callable]] = defaultdict(list)
        self._observe_hooks: dict[ObserveEvent, list[Callable]] = defaultdict(list)

    def register_transform(self, event: TransformEvent, fn: Callable) -> None:
        self._transform_hooks[event].append(fn)

    def register_observe(self, event: ObserveEvent, fn: Callable) -> None:
        self._observe_hooks[event].append(fn)

    def transform(self, event: TransformEvent, value, *args):
        for fn in self._transform_hooks[event]:
            try:
                out = fn(value, *args)
                if out is not None:
                    value = out
            except Exception as exc:  # noqa: BLE001
                _log_hook_error(event, fn, exc)
        return value

    def observe(self, event: ObserveEvent, *args) -> None:
        for fn in self._observe_hooks[event]:
            try:
                fn(*args)
            except Exception as exc:  # noqa: BLE001
                _log_hook_error(event, fn, exc)
```

Then `LLMEgress.on` can route based on the event instead of going through a fully generic bus:

```python
def on(self, event: HookEvent, fn: Callable) -> "LLMEgress":
    if event in ("before_request", "build_payload"):
        self._hooks.register_transform(event, fn)
    else:
        self._hooks.register_observe(event, fn)
    return self
```

This keeps the four-event surface you want but removes the “arbitrary event bus” feel, aligning better with the current usage.

---

### 2. Simplify `complete_sync` threading logic

`complete_sync` currently creates a new `ThreadPoolExecutor` per call and nests `asyncio.run` inside that thread. You can keep the loop-safe behavior but use the existing loop’s `run_in_executor`, avoiding a per-call executor and making the threading + loop interaction easier to reason about:

```python
def complete_sync(self, req: LLMRequest) -> LLMResult:
    """Sync bridge for legacy callers. Loop-safe."""
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        # no event loop: safe to run directly
        return asyncio.run(self.complete(req))

    # already in an event loop: offload to default executor
    return loop.run_until_complete(
        loop.run_in_executor(None, lambda: asyncio.run(self.complete(req)))
    )
```

If `run_until_complete` isn’t available in your embedding context, another option is to move this compatibility wrapper closer to legacy call sites (e.g., a small `complete_sync` helper in the adapter layer) and keep `LLMEgress` purely async. That keeps the core egress simpler while preserving the sync API.
</issue_to_address>

### Comment 3
<location path="core/risk/authority.py" line_range="42" />
<code_context>
+    CRITICAL = 4
+
+
+class Tier(IntEnum):
+    """Authority tier — how much autonomy a verified change is allowed. Ordered so a
+    HIGHER value is MORE restrictive (less autonomy)."""
</code_context>
<issue_to_address>
**issue (complexity):** Consider encapsulating Tier comparison semantics and adding a single high-level orchestration function so callers don’t need to understand enum ordering or manually compose multiple helpers.

You can reduce the cognitive load without changing behavior by:

1. **Encapsulating Tier comparisons** so callers never rely on the inverted IntEnum ordering.
2. **Providing a single “entry point”** that goes from raw inputs to the final authority record, so readers don’t need to chase multiple helpers.

### 1. Encapsulate Tier comparisons

Right now, callers must know that larger `Tier` values mean *more restrictive* and use `max()` / `<=` accordingly. You can centralize that:

```python
def is_more_restrictive(a: Tier, b: Tier) -> bool:
    """True if `a` is strictly more restrictive than `b`."""
    return a > b

def most_restrictive(*tiers: Tier) -> Tier:
    """Return the most restrictive tier from the given values."""
    return max(tiers)

def can_satisfy(required: Tier, ceiling: Tier) -> bool:
    """True if the ceiling is sufficient for the required tier."""
    return required <= ceiling
```

Then use them in the existing functions instead of raw comparisons:

```python
def required_authority(severity: Severity, change_class: ChangeClass) -> Tier:
    tier = _SEVERITY_BASE[severity]
    if change_class.is_fabric_identity:
        tier = most_restrictive(tier, Tier.BLOCK)
    if change_class.touches_spine or change_class.touches_underlay:
        tier = most_restrictive(tier, Tier.HOTL)
    return tier
```

```python
def authorize(required: Tier, max_authorized: Tier) -> AuthorityDecision:
    allowed = can_satisfy(required, max_authorized)
    effective = required if allowed else Tier.BLOCK
    # ... keep the rest as-is ...
```

This keeps behavior identical but makes the unusual ordering explicit and harder to misuse in future changes.

### 2. Provide a single “front door” to the authority record

Right now, readers must mentally chain:

`unify_severity -> required_authority -> authorize -> authority_record`

You can keep these helpers intact but add one orchestrating function a typical caller uses, with the helpers clearly “inside” it:

```python
@dataclass(frozen=True)
class AuthorityInputs:
    batfish_errors: int
    devices_affected: int
    sessions_dropped: int
    converged: bool
    configs: list[GeneratedConfig]
    max_authorized: Tier | None = None  # None means "use deployment default"
```

```python
def classify_and_authorize(inputs: AuthorityInputs) -> dict:
    """Single entry point: raw risk + configs -> sealed authority record."""
    severity = unify_severity(
        batfish_errors=inputs.batfish_errors,
        devices_affected=inputs.devices_affected,
        sessions_dropped=inputs.sessions_dropped,
        converged=inputs.converged,
    )
    change_class = classify_change(inputs.configs)
    max_authorized = (
        inputs.max_authorized if inputs.max_authorized is not None else load_max_authorized()
    )
    return authority_record(severity, change_class, max_authorized)
```

Call sites that “just want the final dictionary” can now use only `classify_and_authorize`, while `authority_record`, `authorize`, `required_authority`, and `unify_severity` remain as composable internals for tests and advanced use.

This keeps all behavior and structure, but significantly reduces the number of concepts a reader has to juggle at each call site.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread core/llm/airgap.py
Comment on lines +48 to +53
try:
return ip_address(host).is_loopback # 127.0.0.0/8 and ::1, strictly
except ValueError:
# Not a literal IP. Only the reserved, non-internet-routable mDNS suffix is
# treated as in-perimeter (RFC 6762); everything else is non-loopback.
return host.endswith(".local")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 question (security): Allowing arbitrary *.local hosts in air-gap mode may be looser than intended for a strict loopback-only policy.

The is_loopback docstring promises “genuine loopback” or the literal localhost / reserved .local mDNS host, but this implementation lets any .local name through. That allows AEGIS_LLM_URL to point at a non-loopback LAN host and still satisfy the air-gap check. To keep air-gap mode strictly loopback-only, consider allowing only literal loopback IPs plus explicit aliases like localhost / ip6-localhost, and removing the generic .local suffix handling so it matches the assert_airgap_ok "non-loopback egress forbidden" invariant.

Comment thread core/llm/egress.py
hints: Mapping[str, object] = field(default_factory=dict)


class HookBus:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider tightening the HookBus API around explicit transform/observe events and simplifying the sync bridge implementation to reduce cognitive and threading complexity without changing behavior.

You can keep all current features but trim some complexity in two focused spots: the hook bus API and the sync bridge.


1. Make HookBus event types explicit

Right now HookBus treats all events uniformly as HookEvent, even though only some are transforming and some are observing. Splitting these at the type/API level keeps the extension surface but makes control flow easier to follow and reduces misuse surface.

# narrow the event types
TransformEvent = Literal["before_request", "build_payload"]
ObserveEvent = Literal["after_response", "on_result"]

class HookBus:
    def __init__(self) -> None:
        self._transform_hooks: dict[TransformEvent, list[Callable]] = defaultdict(list)
        self._observe_hooks: dict[ObserveEvent, list[Callable]] = defaultdict(list)

    def register_transform(self, event: TransformEvent, fn: Callable) -> None:
        self._transform_hooks[event].append(fn)

    def register_observe(self, event: ObserveEvent, fn: Callable) -> None:
        self._observe_hooks[event].append(fn)

    def transform(self, event: TransformEvent, value, *args):
        for fn in self._transform_hooks[event]:
            try:
                out = fn(value, *args)
                if out is not None:
                    value = out
            except Exception as exc:  # noqa: BLE001
                _log_hook_error(event, fn, exc)
        return value

    def observe(self, event: ObserveEvent, *args) -> None:
        for fn in self._observe_hooks[event]:
            try:
                fn(*args)
            except Exception as exc:  # noqa: BLE001
                _log_hook_error(event, fn, exc)

Then LLMEgress.on can route based on the event instead of going through a fully generic bus:

def on(self, event: HookEvent, fn: Callable) -> "LLMEgress":
    if event in ("before_request", "build_payload"):
        self._hooks.register_transform(event, fn)
    else:
        self._hooks.register_observe(event, fn)
    return self

This keeps the four-event surface you want but removes the “arbitrary event bus” feel, aligning better with the current usage.


2. Simplify complete_sync threading logic

complete_sync currently creates a new ThreadPoolExecutor per call and nests asyncio.run inside that thread. You can keep the loop-safe behavior but use the existing loop’s run_in_executor, avoiding a per-call executor and making the threading + loop interaction easier to reason about:

def complete_sync(self, req: LLMRequest) -> LLMResult:
    """Sync bridge for legacy callers. Loop-safe."""
    try:
        loop = asyncio.get_running_loop()
    except RuntimeError:
        # no event loop: safe to run directly
        return asyncio.run(self.complete(req))

    # already in an event loop: offload to default executor
    return loop.run_until_complete(
        loop.run_in_executor(None, lambda: asyncio.run(self.complete(req)))
    )

If run_until_complete isn’t available in your embedding context, another option is to move this compatibility wrapper closer to legacy call sites (e.g., a small complete_sync helper in the adapter layer) and keep LLMEgress purely async. That keeps the core egress simpler while preserving the sync API.

Comment thread core/risk/authority.py
CRITICAL = 4


class Tier(IntEnum):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider encapsulating Tier comparison semantics and adding a single high-level orchestration function so callers don’t need to understand enum ordering or manually compose multiple helpers.

You can reduce the cognitive load without changing behavior by:

  1. Encapsulating Tier comparisons so callers never rely on the inverted IntEnum ordering.
  2. Providing a single “entry point” that goes from raw inputs to the final authority record, so readers don’t need to chase multiple helpers.

1. Encapsulate Tier comparisons

Right now, callers must know that larger Tier values mean more restrictive and use max() / <= accordingly. You can centralize that:

def is_more_restrictive(a: Tier, b: Tier) -> bool:
    """True if `a` is strictly more restrictive than `b`."""
    return a > b

def most_restrictive(*tiers: Tier) -> Tier:
    """Return the most restrictive tier from the given values."""
    return max(tiers)

def can_satisfy(required: Tier, ceiling: Tier) -> bool:
    """True if the ceiling is sufficient for the required tier."""
    return required <= ceiling

Then use them in the existing functions instead of raw comparisons:

def required_authority(severity: Severity, change_class: ChangeClass) -> Tier:
    tier = _SEVERITY_BASE[severity]
    if change_class.is_fabric_identity:
        tier = most_restrictive(tier, Tier.BLOCK)
    if change_class.touches_spine or change_class.touches_underlay:
        tier = most_restrictive(tier, Tier.HOTL)
    return tier
def authorize(required: Tier, max_authorized: Tier) -> AuthorityDecision:
    allowed = can_satisfy(required, max_authorized)
    effective = required if allowed else Tier.BLOCK
    # ... keep the rest as-is ...

This keeps behavior identical but makes the unusual ordering explicit and harder to misuse in future changes.

2. Provide a single “front door” to the authority record

Right now, readers must mentally chain:

unify_severity -> required_authority -> authorize -> authority_record

You can keep these helpers intact but add one orchestrating function a typical caller uses, with the helpers clearly “inside” it:

@dataclass(frozen=True)
class AuthorityInputs:
    batfish_errors: int
    devices_affected: int
    sessions_dropped: int
    converged: bool
    configs: list[GeneratedConfig]
    max_authorized: Tier | None = None  # None means "use deployment default"
def classify_and_authorize(inputs: AuthorityInputs) -> dict:
    """Single entry point: raw risk + configs -> sealed authority record."""
    severity = unify_severity(
        batfish_errors=inputs.batfish_errors,
        devices_affected=inputs.devices_affected,
        sessions_dropped=inputs.sessions_dropped,
        converged=inputs.converged,
    )
    change_class = classify_change(inputs.configs)
    max_authorized = (
        inputs.max_authorized if inputs.max_authorized is not None else load_max_authorized()
    )
    return authority_record(severity, change_class, max_authorized)

Call sites that “just want the final dictionary” can now use only classify_and_authorize, while authority_record, authorize, required_authority, and unify_severity remain as composable internals for tests and advanced use.

This keeps all behavior and structure, but significantly reduces the number of concepts a reader has to juggle at each call site.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant