Skip to content

feat: P1 hardening suite — approval gate, audit anchoring, egress, pre-flight, key-strength#10

Merged
gesh75 merged 2 commits into
mainfrom
claude/p1-hardening-suite
Jun 29, 2026
Merged

feat: P1 hardening suite — approval gate, audit anchoring, egress, pre-flight, key-strength#10
gesh75 merged 2 commits into
mainfrom
claude/p1-hardening-suite

Conversation

@gesh75

@gesh75 gesh75 commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the remaining roadmap items in one suite. Closes #5, closes #6, closes #7, closes #8, and lands the short-term half of #4.

What's in this PR

#6 — Risk-tiered approval gate (approval.py)

--sandbox local now requires a parameter-bound, fail-closed approval token — an HMAC over (mode, canonical-sorted-targets, expiry) keyed by the audit signing key. Mint with aegis approve local <targets> [--ttl N]; pass via --approval / ARGUS_APPROVAL_TOKEN. A token cannot be replayed against different targets or after expiry, and wildcard grants are impossible (every token names its targets). The warning banner no longer "authorizes" anything — no valid token, no run.

#5 — External anchoring + WORM cross-check (anchor.py)

AuditLog mirrors the chain tip {seq, tip, ts} to an out-of-band anchor file after every entry; aegis audit cross-checks the live chain against it. This catches a full-log rewrite that re-verifies under a leaked key (demonstrated: chain VALID ✅ but anchor MISMATCH ❌, exit 1). Enabled via audit.anchor_path; point it at S3 Object Lock / a WORM volume in production (format is ready).

#7 — Network-layer egress control (egress.py)

aegis egress-rules emits a default-drop nftables allow-list derived from the scope policy (denied ranges carved out first). Apply with nft -f on the disposable recon host before --sandbox local — the missing second barrier now that the container boundary is gone.

#8 — Pre-flight checks (preflight.py)

Before an un-isolated local run, warns on public-IP targets, an over-broad allow-list (> a /24), or resolve_dns enabled — catching misconfiguration before any packet.

#4 (short-term) — Audit-key strength

AuditLog now fails closed on a weak audit key (< 32 chars) so a placeholder key can't sign a "tamper-evident" chain. The full out-of-band signer process remains tracked under #4.

Testing

  • 95 passed (+14 new in tests/test_p1_hardening.py; covers approval roundtrip/replay/expiry/wrong-key, anchor tracking + rewrite detection, egress ruleset, pre-flight, short-key refusal).
  • Ruff + Bandit (medium+) clean; new CI gates (hashed install, pip-audit, SBOM) unaffected.
  • CLI smoke: approve→token, scan-local without token → REFUSED, valid token passes, wrong-target token rejected, egress-rules valid nft, audit+anchor tamper detection.

Follow-up (still tracked under #4)

Full out-of-band signer process (orchestrator never holds the key) + wiring audit.anchor_path to an actual S3 Object Lock / KMS store — operational, environment-specific.

🤖 Generated with Claude Code


Generated by Claude Code

Summary by Sourcery

Introduce a P1 hardening suite that adds an approval gate for high‑risk modes, external audit anchoring, network egress controls, pre‑flight checks, and an audit-key strength guardrail.

New Features:

  • Add parameter-bound approval tokens and an aegis approve CLI for --sandbox local and --arm high-risk modes.
  • Add external audit-chain anchoring with an optional anchor file and aegis audit cross-check support.
  • Add an aegis egress-rules CLI that generates an nftables egress allow-list from the scope policy.
  • Add pre-flight checks that warn on risky local-run configurations such as public targets, broad scopes, and DNS resolution.

Enhancements:

  • Enforce a minimum audit signing key length so weak or placeholder keys cannot be used for the tamper-evident audit chain.

Documentation:

  • Update NEXT_STEPS documentation to mark P1 hardening items as completed, describe the new approval, anchoring, egress, and pre-flight mechanisms, and clarify remaining work.

Tests:

  • Add a dedicated P1 hardening test suite covering approval token behavior, audit-key strength enforcement, anchor tracking and mismatch detection, egress ruleset generation, and pre-flight warnings.

…e-flight, key-strength

Implements the remaining roadmap issues (#4 partial, #5, #6, #7, #8).

#6 approval gate (approval.py): --sandbox local now requires a parameter-bound,
   fail-closed approval token (HMAC over mode + canonical-sorted-targets + expiry,
   keyed by the audit key). Mint with `aegis approve local <targets> [--ttl N]`,
   pass via --approval / ARGUS_APPROVAL_TOKEN. Cannot be replayed against other
   targets or after expiry; wildcard grants are impossible. The banner no longer
   "authorizes" — no token, no run.

#5 audit anchoring (anchor.py): AuditLog mirrors the chain tip {seq, tip, ts} to an
   out-of-band anchor after every entry; `aegis audit` cross-checks it. Detects a
   full-log rewrite that re-verifies under a leaked key (HMAC-VALID but anchor
   MISMATCH). Enabled via audit.anchor_path (point at S3 Object Lock / WORM in prod).

#7 egress control (egress.py): `aegis egress-rules` emits a default-drop nftables
   allow-list from the scope policy (denied ranges carved out first) for the
   disposable recon host — the missing network-layer second barrier for local mode.

#8 pre-flight (preflight.py): warns on public-IP targets / over-broad scope /
   resolve_dns before any packet on the un-isolated path.

#4 (short-term): AuditLog fails closed on a weak audit key (<32 chars) so a
   placeholder key can't sign a "tamper-evident" chain. Full out-of-band signer
   remains tracked.

Config: Policy.audit_anchor_path; commented anchor_path example in scope-policy.yaml.
Tests: +14 (test_p1_hardening.py); test fixtures bumped to 32-char keys. 95 passed,
ruff + Bandit(medium+) clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PH7w1wBoUxFESq9gh6yjAQ
@sourcery-ai

sourcery-ai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements a P1 hardening suite around local sandbox runs and audit logging: adds parameter-bound approval tokens and CLI, external audit-chain anchoring with cross-checks, nftables egress rules generation from policy, pre-flight configuration checks for risky local runs, and a minimum audit-key length, plus wiring into policy, CLI, scope-policy, docs, and tests.

Sequence diagram for approval gate and pre-flight in aegis scan

sequenceDiagram
    actor Operator
    participant cli_cmd_scan as cmd_scan
    participant Guard as Guardrail
    participant Preflight as preflight
    participant Approval as approval

    Operator->>cli_cmd_scan: aegis scan --sandbox local --approval TOKEN
    cli_cmd_scan->>Guard: check_target(t)
    Guard-->>cli_cmd_scan: Decision.allowed / reason
    cli_cmd_scan->>Preflight: check(args.targets, guard.policy)
    Preflight-->>cli_cmd_scan: warnings
    cli_cmd_scan->>Approval: verify(args.approval, required, args.targets, audit_key)
    Approval-->>cli_cmd_scan: ok / ApprovalError
    cli_cmd_scan-->>Operator: REFUSED or proceed with sandbox
Loading

Sequence diagram for audit verification with external anchor cross-check

sequenceDiagram
    actor Operator
    participant cli_cmd_audit as cmd_audit
    participant Guard as Guardrail
    participant Audit as AuditLog
    participant Anchor as anchor

    Operator->>cli_cmd_audit: aegis audit
    cli_cmd_audit->>Guard: _guard(args)
    Guard-->>cli_cmd_audit: guard
    cli_cmd_audit->>Audit: verify()
    Audit-->>cli_cmd_audit: ok
    cli_cmd_audit->>Audit: cross_check_anchor()
    Audit->>Anchor: read_anchor(audit_anchor_path)
    Anchor-->>Audit: anchor_record
    Audit-->>cli_cmd_audit: anchored, reason
    cli_cmd_audit-->>Operator: audit chain VALID/TAMPERED and anchor OK/MISMATCH
Loading

File-Level Changes

Change Details Files
Introduce parameter-bound approval tokens and enforce them for high-risk run modes.
  • Add approval.mint/verify utilities that HMAC over canonical modes, targets, and expiry keyed by the audit key, raising ApprovalError on mismatch/expiry/malformed tokens.
  • Require approval tokens in cmd_scan for --sandbox local and --arm, including pre-flight warnings for local runs and failure-close behavior on missing/invalid tokens.
  • Add a new aegis approve subcommand that scope-validates targets, selects modes, and prints minted tokens, and plumb an --approval flag/ARGUS_APPROVAL_TOKEN env into the scan CLI.
aegis/aegis/approval.py
aegis/aegis/cli.py
Add external audit-chain anchoring and cross-checking, plus audit-key strength enforcement.
  • Enforce a minimum audit-key length (MIN_AUDIT_KEY_LEN=32) in AuditLog initialization, failing closed with GuardrailError on short keys.
  • Extend AuditLog to track entry count, mirror the chain tip to an anchor file on each write, and expose cross_check_anchor to compare live chain state with the anchor.
  • Introduce an anchor module that defines a simple JSON anchor format and helpers for writing/reading anchors, and wire audit.anchor_path through Policy and scope-policy.yaml.
  • Update cmd_audit to report anchor status and fail overall verification when the anchor mismatches.
aegis/aegis/guardrail.py
aegis/aegis/anchor.py
aegis/aegis/config.py
targets/scope-policy.yaml
aegis/aegis/cli.py
Provide nftables egress allow-list generation from the scope policy for local runs.
  • Implement egress.nftables_ruleset to emit a deterministic nftables script that default-drops output, allows loopback/established traffic, explicitly drops denied CIDRs, and allows only the policy’s allowed networks.
  • Expose a new aegis egress-rules CLI subcommand that prints this ruleset to stdout.
aegis/aegis/egress.py
aegis/aegis/cli.py
Implement pre-flight checks for un-isolated local runs to surface misconfigurations.
  • Add preflight.check to warn on public-IP targets, special-use addresses, overly broad allow-lists (> /24), and resolve_dns being enabled, plus an is_lab_only helper.
  • Invoke preflight.check from cmd_scan when approval is required for local runs, printing warnings to stderr before any execution.
aegis/aegis/preflight.py
aegis/aegis/cli.py
Update documentation, policy defaults, and tests for the P1 hardening suite. aegis/docs/NEXT_STEPS.md
aegis/aegis/config.py
targets/scope-policy.yaml
aegis/tests/test_p1_hardening.py
aegis/tests/test_agent.py
aegis/tests/test_guardrail.py
aegis/tests/test_integration_agentic.py
aegis/tests/test_recon_modules.py
aegis/tests/test_webrecon.py

Assessment against linked issues

Issue Objective Addressed Explanation
#5 Implement external anchoring for the audit chain by writing the latest chain tip to an out-of-band destination and cross-checking it during audit/verification to detect full-log rewrites even after key compromise.
#5 Expose configuration and documentation for the audit-chain anchoring mechanism, including how to point the anchor at a WORM/write-once store in production.
#6 Replace the existing stderr warning banner for --sandbox local (and include --arm) with a real, enforced approval gate that fails closed when approval is missing or invalid.
#6 Implement a parameter-bound approval mechanism that binds approval to the exact action (mode/tool + normalized targets + expiry) and forbids wildcard or reusable grants for the un-isolated local path.
#7 Add a documented, network-layer egress allow-list helper (e.g., nftables) for the --sandbox local path, derived from the scope policy, to provide a second enforcement layer beyond the app-layer guardrail.
#8 Implement pre-flight checks for --sandbox local that, before any network traffic is sent, validate configuration and surface warnings when the targets or scope resemble production or otherwise risky setups.

Possibly linked issues

  • #P1.6: The PR replaces the banner with a parameter-bound, fail-closed approval token for --sandbox local/--arm, matching P1.6.
  • #P1.7: PR’s egress.py and aegis egress-rules exactly implement P1.7’s network-layer egress control for --sandbox local.

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

Per review feedback: #6 scoped the gate to --sandbox local AND --arm, but the
first pass covered only local. Generalize the approval token to authorize a SET
of modes ({local, arm}); cmd_scan now requires:
 - a `local`-scoped token for --sandbox local (un-isolated host exec),
 - an `arm`-scoped token for --arm (exploit-capable tools) in any sandbox,
 - a token authorizing BOTH when used together.
Tokens are exact-match on the canonical mode set (a {local,arm} token does not
satisfy a {local}-only requirement), fail closed, and dry-runs need none.

`aegis approve <targets> [--mode local] [--mode arm] [--ttl N]` mints them.
Adds combined-mode + arm-mode tests. 97 passed; ruff + Bandit clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PH7w1wBoUxFESq9gh6yjAQ
@gesh75
gesh75 marked this pull request as ready for review June 29, 2026 02:00
@gesh75
gesh75 merged commit 499024a into main Jun 29, 2026
5 checks passed

@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 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="aegis/aegis/cli.py" line_range="216-217" />
<code_context>
+def cmd_approve(args) -> int:
+    """Mint a parameter-bound approval token for a high-risk run mode (#6)."""
+    policy = Policy.load(args.policy)
+    key = os.environ.get(policy.audit_key_env)
+    if not key:
+        print(f"REFUSED: audit key env {policy.audit_key_env} unset", file=sys.stderr)
+        return 2
</code_context>
<issue_to_address>
**🚨 suggestion (security):** Approval minting only checks that the audit key env is non-empty, not that it meets the minimum key-length requirement enforced elsewhere.

AuditLog enforces `MIN_AUDIT_KEY_LEN`, but `cmd_approve` only checks that `audit_key_env` is non-empty. This lets operators mint approval tokens with weak or placeholder keys, breaking the security assumptions shared with the audit chain. Please reuse the same minimum-length check (or initialize AuditLog here) so approval minting fails on weak keys.

Suggested implementation:

```python
def cmd_approve(args) -> int:
    """Mint a parameter-bound approval token for a high-risk run mode (#6)."""
    policy = Policy.load(args.policy)

    # Ensure the audit key meets the same minimum-length requirement as the audit log.
    audit_key = os.environ.get(policy.audit_key_env)
    if not audit_key:
        print(f"REFUSED: audit key env {policy.audit_key_env} unset", file=sys.stderr)
        return 2
    if len(audit_key) < AuditLog.MIN_AUDIT_KEY_LEN:
        print(
            f"REFUSED: audit key env {policy.audit_key_env} too short "
            f"(len={len(audit_key)}, min={AuditLog.MIN_AUDIT_KEY_LEN})",
            file=sys.stderr,
        )
        return 2

    # Fail-closed, parameter-bound approval gate for high-risk modes — banners don't
    # enforce (#6). `local` = un-isolated host exec; `arm` = exploit-capable tools.
    # Dry-run executes nothing, so it needs no approval.
    required = []

```

To fully implement this change you will also need to:

1. Ensure `os`, `sys`, and `AuditLog` are imported at the top of `aegis/aegis/cli.py`. For example, add:
   - `import os`
   - `import sys`
   - `from aegis.audit import AuditLog` (or the correct module path where `AuditLog` is defined).

2. If `AuditLog.MIN_AUDIT_KEY_LEN` is not a public attribute, expose or import the constant used by `AuditLog` for the minimum key length and reference that instead.

3. If `cmd_approve` needs the actual key material later (e.g., to mint the token), reuse the validated `audit_key` variable rather than re-reading from the environment.
</issue_to_address>

### Comment 2
<location path="aegis/aegis/egress.py" line_range="26-34" />
<code_context>
+        "#!/usr/sbin/nft -f",
+        "# Argus egress allow-list — generated from the scope policy. Apply on the",
+        "# disposable recon host before `--sandbox local`. Fail-closed: default drop.",
+        f"add table inet {_TABLE}",
+        f"delete table inet {_TABLE}",
+        f"table inet {_TABLE} {{",
+        "  chain output {",
+        "    type filter hook output priority 0; policy drop;",
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The generated nftables script adds and then deletes the table before redefining it, which is confusing and potentially error-prone.

The `add``delete``table` sequence is redundant and may have unintended effects depending on nftables semantics. A clearer, safer pattern is to (optionally) `delete` the existing table first, then define it once with the `table inet ... {}` block.

```suggestion
    lines = [
        "#!/usr/sbin/nft -f",
        "# Argus egress allow-list — generated from the scope policy. Apply on the",
        "# disposable recon host before `--sandbox local`. Fail-closed: default drop.",
        f"delete table inet {_TABLE}",
        f"table inet {_TABLE} {{",
        "  chain output {",
        "    type filter hook output priority 0; policy drop;",
```
</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 aegis/aegis/cli.py
Comment on lines +216 to +217
key = os.environ.get(policy.audit_key_env)
if not key:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 suggestion (security): Approval minting only checks that the audit key env is non-empty, not that it meets the minimum key-length requirement enforced elsewhere.

AuditLog enforces MIN_AUDIT_KEY_LEN, but cmd_approve only checks that audit_key_env is non-empty. This lets operators mint approval tokens with weak or placeholder keys, breaking the security assumptions shared with the audit chain. Please reuse the same minimum-length check (or initialize AuditLog here) so approval minting fails on weak keys.

Suggested implementation:

def cmd_approve(args) -> int:
    """Mint a parameter-bound approval token for a high-risk run mode (#6)."""
    policy = Policy.load(args.policy)

    # Ensure the audit key meets the same minimum-length requirement as the audit log.
    audit_key = os.environ.get(policy.audit_key_env)
    if not audit_key:
        print(f"REFUSED: audit key env {policy.audit_key_env} unset", file=sys.stderr)
        return 2
    if len(audit_key) < AuditLog.MIN_AUDIT_KEY_LEN:
        print(
            f"REFUSED: audit key env {policy.audit_key_env} too short "
            f"(len={len(audit_key)}, min={AuditLog.MIN_AUDIT_KEY_LEN})",
            file=sys.stderr,
        )
        return 2

    # Fail-closed, parameter-bound approval gate for high-risk modes — banners don't
    # enforce (#6). `local` = un-isolated host exec; `arm` = exploit-capable tools.
    # Dry-run executes nothing, so it needs no approval.
    required = []

To fully implement this change you will also need to:

  1. Ensure os, sys, and AuditLog are imported at the top of aegis/aegis/cli.py. For example, add:

    • import os
    • import sys
    • from aegis.audit import AuditLog (or the correct module path where AuditLog is defined).
  2. If AuditLog.MIN_AUDIT_KEY_LEN is not a public attribute, expose or import the constant used by AuditLog for the minimum key length and reference that instead.

  3. If cmd_approve needs the actual key material later (e.g., to mint the token), reuse the validated audit_key variable rather than re-reading from the environment.

Comment thread aegis/aegis/egress.py
Comment on lines +26 to +34
lines = [
"#!/usr/sbin/nft -f",
"# Argus egress allow-list — generated from the scope policy. Apply on the",
"# disposable recon host before `--sandbox local`. Fail-closed: default drop.",
f"add table inet {_TABLE}",
f"delete table inet {_TABLE}",
f"table inet {_TABLE} {{",
" chain output {",
" type filter hook output priority 0; policy drop;",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (bug_risk): The generated nftables script adds and then deletes the table before redefining it, which is confusing and potentially error-prone.

The adddeletetable sequence is redundant and may have unintended effects depending on nftables semantics. A clearer, safer pattern is to (optionally) delete the existing table first, then define it once with the table inet ... {} block.

Suggested change
lines = [
"#!/usr/sbin/nft -f",
"# Argus egress allow-list — generated from the scope policy. Apply on the",
"# disposable recon host before `--sandbox local`. Fail-closed: default drop.",
f"add table inet {_TABLE}",
f"delete table inet {_TABLE}",
f"table inet {_TABLE} {{",
" chain output {",
" type filter hook output priority 0; policy drop;",
lines = [
"#!/usr/sbin/nft -f",
"# Argus egress allow-list — generated from the scope policy. Apply on the",
"# disposable recon host before `--sandbox local`. Fail-closed: default drop.",
f"delete table inet {_TABLE}",
f"table inet {_TABLE} {{",
" chain output {",
" type filter hook output priority 0; policy drop;",

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

Labels

None yet

Projects

None yet

2 participants