# Phase 7e.2 Firewall Audit Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add three new server-audit Checks (`iptables_audit`, `nftables_audit`, `ufw_audit`) that detect default-policy / backend-active / IPv6-parallel firewall regressions on Linux hosts, gated by best-effort permission handling.

**Architecture:** Per-backend Check split (mirrors Phase 7f Docker three-Check pattern). Each Check probes its backend binary via a shared `_firewall_common.detect_<bin>(ctx)` helper at the top of `run()`; missing binary → `not_applicable`. Cross-talk avoidance (Approach A from spec §3.4): `iptables_audit` and `nftables_audit` detect ufw-injected chains in their own output and yield to `ufw_audit` rather than double-reporting.

**Tech Stack:** Python 3.11+ async Check ABC, `ssh_policy.yaml` strict argv allow-list, `linux_hardening_baseline.yaml` rationale/remediation, pytest + dict-stub `_RecordingExecutor`.

**Spec:** `docs/superpowers/specs/2026-05-09-phase-7e2-firewall-design.md`.

---

## Files to be Created or Modified

**Create:**
- `src/agentsec/audit/checks/_firewall_common.py` — `detect_iptables`, `detect_nft`, `detect_ufw`, `output_managed_by_ufw`
- `src/agentsec/audit/checks/iptables_audit.py` — `IptablesAuditCheck`
- `src/agentsec/audit/checks/nftables_audit.py` — `NftablesAuditCheck`
- `src/agentsec/audit/checks/ufw_audit.py` — `UfwAuditCheck`
- `tests/test_firewall_common.py`
- `tests/test_iptables_audit.py`
- `tests/test_nftables_audit.py`
- `tests/test_ufw_audit.py`
- `tests/fixtures/firewall/iptables_default_drop_v4.txt`
- `tests/fixtures/firewall/iptables_default_accept_v4.txt`
- `tests/fixtures/firewall/iptables_with_ufw_chains.txt`
- `tests/fixtures/firewall/ip6tables_default_accept.txt`
- `tests/fixtures/firewall/nft_ruleset_drop.json`
- `tests/fixtures/firewall/nft_ruleset_accept.json`
- `tests/fixtures/firewall/nft_ruleset_with_ufw.json`
- `tests/fixtures/firewall/ufw_active_default_deny.txt`
- `tests/fixtures/firewall/ufw_active_default_allow.txt`
- `tests/fixtures/firewall/ufw_inactive.txt`
- `docs/adr/0011-phase-7e2-firewall-audit.md`

**Modify:**
- `src/agentsec/audit/ssh_policy.yaml` — append 9 `allowed_paths` entries + 3 `commands` sections
- `src/agentsec/audit/checks/linux_hardening_baseline.yaml` — append `firewall:` section
- `src/agentsec/audit/ioc/threat_intel.yaml` — append `TI-LINUX-CIS-FIREWALL` entry
- `src/agentsec/audit/ioc/cve_database.json` — regenerated by `scripts/build_cve_db.py`
- `src/agentsec/cli.py` — register 3 Checks in two sites (`server_audit` ~line 524, `_evaluate_server` ~line 823) + 3 imports
- `tests/integration/test_openclaw_mock.py` — extend SSH stub with 9 firewall stat-probe responses
- `docs/samples/report-2026-04-28/*` — refresh via `AGENTSEC_REFRESH_SAMPLE=1`
- `pyproject.toml` — bump version to `0.11.0`
- `ROADMAP.md`, `CHANGELOG.md`, `README.md`, `CLAUDE.md` — version + Check count + 7e.2 entry

---

## Task 1: Data Infrastructure (ssh_policy + threat_intel + baseline YAML)

**Files:**
- Modify: `src/agentsec/audit/ssh_policy.yaml`
- Modify: `src/agentsec/audit/ioc/threat_intel.yaml`
- Modify: `src/agentsec/audit/ioc/cve_database.json` (regenerated)
- Modify: `src/agentsec/audit/checks/linux_hardening_baseline.yaml`

Pure data-config task — sets up everything the Python Checks will read in later tasks. No new tests of our own; `tests/test_command_policy.py` provides automatic regression coverage of `ssh_policy.yaml`.

- [ ] **Step 1: Append 9 firewall binary paths to `ssh_policy.yaml:allowed_paths`**

Locate the `allowed_paths:` section in `src/agentsec/audit/ssh_policy.yaml`. Right after the docker bin-resolver block (the last `{kind: exact, path: "/opt/homebrew/bin/docker"}` line and before the `{kind: prefix, path: "/etc/cron.d/"}` line), insert:

```yaml
  # firewall bin-resolver candidates (Phase 7e.2) — stat-probe only, exact paths
  - {kind: exact,  path: "/usr/sbin/iptables"}
  - {kind: exact,  path: "/sbin/iptables"}
  - {kind: exact,  path: "/usr/bin/iptables"}
  - {kind: exact,  path: "/usr/sbin/nft"}
  - {kind: exact,  path: "/sbin/nft"}
  - {kind: exact,  path: "/usr/bin/nft"}
  - {kind: exact,  path: "/usr/sbin/ufw"}
  - {kind: exact,  path: "/sbin/ufw"}
  - {kind: exact,  path: "/usr/bin/ufw"}
```

- [ ] **Step 2: Append 3 firewall command sections to `ssh_policy.yaml:commands`**

At the end of the `commands:` mapping (after the existing `docker:` block, before the `# ---- write operations ----` comment), insert:

```yaml
  iptables:
    # Phase 7e.2 — only -S (rule list). Variants such as -L / -F / -A / -D
    # / -I / -P / --line-numbers are NOT in any allowed_argv tuple and so
    # are unreachable by construction. ip6tables shares the same shape via
    # its own argv tuple.
    allowed_argv:
      - ["iptables",  "-S"]
      - ["ip6tables", "-S"]

  nft:
    # Phase 7e.2 — JSON dump of the full ruleset. add / delete / flush /
    # create / insert / replace are NOT in any allowed_argv tuple.
    allowed_argv:
      - ["nft", "-j", "list", "ruleset"]

  ufw:
    # Phase 7e.2 — read-only status verbose. enable / disable / allow /
    # deny / delete / reset / reload are NOT in any allowed_argv tuple.
    allowed_argv:
      - ["ufw", "status", "verbose"]
```

- [ ] **Step 3: Run command-policy tests to verify ssh_policy is well-formed**

Run: `.venv/bin/pytest tests/test_command_policy.py -q`
Expected: All tests pass (existing tests automatically validate every `allowed_argv` tuple shape).

- [ ] **Step 4: Append `TI-LINUX-CIS-FIREWALL` to `threat_intel.yaml`**

Append at the very end of `src/agentsec/audit/ioc/threat_intel.yaml`:

```yaml
- id: TI-LINUX-CIS-FIREWALL
  source: internal
  claim: 主机防火墙默认策略与后端激活 — CIS Linux 加固基线
  confidence: high
  observed_at: '2026-05-09'
  url: ''
  cvss: null
  cvss_source: null
  affected_versions: ''
  fixed_in: ''
  kev: false
  mapped_tests: []
  auto_refresh: false
  note: 主机加固类静态规则；不需要外部刷新
```

- [ ] **Step 5: Regenerate `cve_database.json`**

Run: `.venv/bin/python scripts/build_cve_db.py`
Expected: Exit 0. The new TI entry has no `TI-*-CVE-*` shape so `cve_database.json` is unchanged in payload — but re-running guarantees the file matches the updated source.

Then verify: `.venv/bin/python scripts/build_cve_db.py --check`
Expected: Exit 0 ("up to date").

- [ ] **Step 6: Append `firewall` section to `linux_hardening_baseline.yaml`**

At the end of `src/agentsec/audit/checks/linux_hardening_baseline.yaml`, append:

```yaml

firewall:                    # 7e.2
  rationale: 默认接收策略 / 后端未激活 / IPv6 平行漏洞 — CIS Linux 加固基线
  remediation: 设置 INPUT/FORWARD 默认策略为 DROP；启用 ufw / 装 nftables 规则；同步配置 IPv6
```

- [ ] **Step 7: Run threat-ref consistency check + full test suite gate**

Run: `.venv/bin/python scripts/check_threat_refs.py`
Expected: Exit 0 (the new TI has empty `mapped_tests`, no constraint).

Run: `.venv/bin/pytest tests/ -q`
Expected: 1094 passed (or current main count); no new failures.

- [ ] **Step 8: Commit**

```bash
git add src/agentsec/audit/ssh_policy.yaml \
        src/agentsec/audit/ioc/threat_intel.yaml \
        src/agentsec/audit/ioc/cve_database.json \
        src/agentsec/audit/checks/linux_hardening_baseline.yaml
git commit -m "$(cat <<'EOF'
data(7e.2): ssh_policy + TI-LINUX-CIS-FIREWALL + baseline section

Reserve argv allow-list for iptables -S / ip6tables -S / nft -j list
ruleset / ufw status verbose. Add 9 firewall bin-resolver exact paths.
Seed TI-LINUX-CIS-FIREWALL stub. Add firewall section in
linux_hardening_baseline.yaml carrying shared rationale/remediation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: `_firewall_common.py` Helper Module

**Files:**
- Create: `src/agentsec/audit/checks/_firewall_common.py`
- Test: `tests/test_firewall_common.py`

Mirrors `src/agentsec/audit/checks/_docker_common.py:detect_docker` shape exactly.

- [ ] **Step 1: Write the failing test file**

Create `tests/test_firewall_common.py`:

```python
"""_firewall_common helper tests (Phase 7e.2)."""

from __future__ import annotations

from pathlib import Path

from agentsec.audit.checks._firewall_common import (
    detect_iptables,
    detect_nft,
    detect_ufw,
    output_managed_by_ufw,
)
from agentsec.audit.checks.base import CheckContext
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult


class _StubExecutor:
    """Returns exit_code=0 for `stat -c %n <found_path>`; exit 1 otherwise."""

    def __init__(self, found_path: str | None):
        self._found_path = found_path

    def run(self, argv, *, role=None):
        argv = list(argv)
        if argv[:3] == ["stat", "-c", "%n"] and argv[3] == self._found_path:
            return RunResult(
                stdout=self._found_path + "\n", stderr="", exit_code=0,
                rejected=False, reject_reason=None, argv=argv,
                command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
            )
        return RunResult(
            stdout="", stderr="No such file", exit_code=1,
            rejected=False, reject_reason=None, argv=argv,
            command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
        )


class _AllRejectedExecutor:
    def run(self, argv, *, role=None):
        return RunResult(
            stdout="", stderr="", exit_code=0,
            rejected=True, reject_reason="policy", argv=list(argv),
            command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
        )


def _ctx(executor):
    profile = materialize_paths(LINUX, "/home/u")
    return CheckContext(
        executor=executor, redactor=None, ioc_dir=Path("/tmp"),
        profile=profile, log_review_config=None,
    )


# ---- detect_iptables ----

def test_detect_iptables_finds_usr_sbin():
    assert detect_iptables(_ctx(_StubExecutor("/usr/sbin/iptables"))) == "/usr/sbin/iptables"


def test_detect_iptables_finds_sbin():
    assert detect_iptables(_ctx(_StubExecutor("/sbin/iptables"))) == "/sbin/iptables"


def test_detect_iptables_finds_usr_bin():
    assert detect_iptables(_ctx(_StubExecutor("/usr/bin/iptables"))) == "/usr/bin/iptables"


def test_detect_iptables_returns_none_when_absent():
    assert detect_iptables(_ctx(_StubExecutor(None))) is None


def test_detect_iptables_returns_none_when_all_rejected():
    assert detect_iptables(_ctx(_AllRejectedExecutor())) is None


# ---- detect_nft ----

def test_detect_nft_finds_usr_sbin():
    assert detect_nft(_ctx(_StubExecutor("/usr/sbin/nft"))) == "/usr/sbin/nft"


def test_detect_nft_returns_none_when_absent():
    assert detect_nft(_ctx(_StubExecutor(None))) is None


# ---- detect_ufw ----

def test_detect_ufw_finds_usr_sbin():
    assert detect_ufw(_ctx(_StubExecutor("/usr/sbin/ufw"))) == "/usr/sbin/ufw"


def test_detect_ufw_returns_none_when_absent():
    assert detect_ufw(_ctx(_StubExecutor(None))) is None


# ---- output_managed_by_ufw ----

def test_output_managed_by_ufw_iptables_self_chain():
    s = "-N ufw-before-input\n-A INPUT -j ACCEPT\n"
    assert output_managed_by_ufw(s, backend="iptables") is True


def test_output_managed_by_ufw_iptables_hook_line():
    s = "-A INPUT -j ufw-before-input\n"
    assert output_managed_by_ufw(s, backend="iptables") is True


def test_output_managed_by_ufw_iptables_comment_tag():
    s = '-A INPUT -m comment --comment "[ufw allow ssh]"\n'
    assert output_managed_by_ufw(s, backend="iptables") is True


def test_output_managed_by_ufw_iptables_clean():
    s = "-P INPUT DROP\n-A INPUT -j ACCEPT\n"
    assert output_managed_by_ufw(s, backend="iptables") is False


def test_output_managed_by_ufw_nftables_table_name():
    s = '{"nftables": [{"table": {"family": "inet", "name": "ufw"}}]}'
    assert output_managed_by_ufw(s, backend="nftables") is True


def test_output_managed_by_ufw_nftables_clean():
    s = '{"nftables": [{"table": {"family": "inet", "name": "filter"}}]}'
    assert output_managed_by_ufw(s, backend="nftables") is False
```

- [ ] **Step 2: Run tests to verify they fail with import error**

Run: `.venv/bin/pytest tests/test_firewall_common.py -q`
Expected: All tests collected; all fail with `ModuleNotFoundError: agentsec.audit.checks._firewall_common`.

- [ ] **Step 3: Implement `_firewall_common.py`**

Create `src/agentsec/audit/checks/_firewall_common.py`:

```python
"""Phase 7e.2 firewall Check 共享 helper。"""

from __future__ import annotations

from .base import CheckContext

_IPTABLES_BIN_CANDIDATES: tuple[str, ...] = (
    "/usr/sbin/iptables",
    "/sbin/iptables",
    "/usr/bin/iptables",
)

_NFT_BIN_CANDIDATES: tuple[str, ...] = (
    "/usr/sbin/nft",
    "/sbin/nft",
    "/usr/bin/nft",
)

_UFW_BIN_CANDIDATES: tuple[str, ...] = (
    "/usr/sbin/ufw",
    "/sbin/ufw",
    "/usr/bin/ufw",
)


def _stat_probe(ctx: CheckContext, candidates: tuple[str, ...]) -> str | None:
    for path in candidates:
        result = ctx.executor.run(["stat", "-c", "%n", path])
        if not result.rejected and result.exit_code == 0:
            return path
    return None


def detect_iptables(ctx: CheckContext) -> str | None:
    """探测 iptables 二进制路径；首个 stat 命中即返，全失败返 None。"""
    return _stat_probe(ctx, _IPTABLES_BIN_CANDIDATES)


def detect_nft(ctx: CheckContext) -> str | None:
    """探测 nft 二进制路径；首个 stat 命中即返，全失败返 None。"""
    return _stat_probe(ctx, _NFT_BIN_CANDIDATES)


def detect_ufw(ctx: CheckContext) -> str | None:
    """探测 ufw 二进制路径；首个 stat 命中即返，全失败返 None。"""
    return _stat_probe(ctx, _UFW_BIN_CANDIDATES)


def output_managed_by_ufw(stdout: str, *, backend: str) -> bool:
    """检测目标后端的 dump 是否被 ufw 接管，避免 iptables/nftables Check
    与 ufw_audit 双报。

    backend="iptables"：扫三种痕迹（自建链 / hook 行 / [ufw 注释 tag）。
    backend="nftables"：扫 JSON 顶层 ufw 表。
    """
    if backend == "iptables":
        if "-N ufw-" in stdout or "-A ufw-" in stdout:
            return True
        if "-j ufw-before-input" in stdout or "-j ufw-reject-input" in stdout:
            return True
        if "[ufw" in stdout:
            return True
        return False
    if backend == "nftables":
        # 扫 '"name": "ufw"' 的 JSON 字段（容忍空格变化）
        normalized = stdout.replace(" ", "")
        return '"name":"ufw"' in normalized
    raise ValueError(f"unknown backend {backend!r}")
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/test_firewall_common.py -q`
Expected: All 15 tests pass.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/checks/_firewall_common.py tests/test_firewall_common.py
git commit -m "$(cat <<'EOF'
feat(7e.2): _firewall_common helper — detect_* + output_managed_by_ufw

Mirrors _docker_common.detect_docker shape. Three detect functions probe
3 candidate paths each via stat -c %n. output_managed_by_ufw detects
ufw-injected chains/comments in iptables output and ufw table in
nftables JSON, used by iptables_audit / nftables_audit to defer to
ufw_audit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: `IptablesAuditCheck`

**Files:**
- Create: `src/agentsec/audit/checks/iptables_audit.py`
- Test: `tests/test_iptables_audit.py`
- Test fixtures: `tests/fixtures/firewall/iptables_default_drop_v4.txt`, `iptables_default_accept_v4.txt`, `iptables_with_ufw_chains.txt`, `ip6tables_default_accept.txt`

- [ ] **Step 1: Write fixtures**

Create `tests/fixtures/firewall/iptables_default_drop_v4.txt`:

```
-P INPUT DROP
-P FORWARD DROP
-P OUTPUT ACCEPT
-A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p tcp -m tcp --dport 22 -j ACCEPT
```

Create `tests/fixtures/firewall/iptables_default_accept_v4.txt`:

```
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-A INPUT -i lo -j ACCEPT
```

Create `tests/fixtures/firewall/iptables_with_ufw_chains.txt`:

```
-P INPUT ACCEPT
-P FORWARD DROP
-P OUTPUT ACCEPT
-N ufw-before-input
-N ufw-before-output
-N ufw-reject-input
-A INPUT -j ufw-before-input
-A INPUT -j ufw-reject-input
```

Create `tests/fixtures/firewall/ip6tables_default_accept.txt`:

```
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
```

- [ ] **Step 2: Write the failing test file**

Create `tests/test_iptables_audit.py`:

```python
"""IptablesAuditCheck unit tests (Phase 7e.2)."""

from __future__ import annotations

import asyncio
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.iptables_audit import IptablesAuditCheck
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult


_FIXTURES = Path(__file__).parent / "fixtures" / "firewall"

_BIN = "/usr/sbin/iptables"
_STAT_OK = ("stat", "-c", "%n", _BIN)
_IPTABLES_S = ("iptables", "-S")
_IP6TABLES_S = ("ip6tables", "-S")


class _RecordingExecutor:
    def __init__(self, responses: dict[tuple, RunResult]):
        self._responses = responses
        self.calls: list[list[str]] = []

    def run(self, argv, *, role=None):
        argv = list(argv)
        self.calls.append(argv)
        return self._responses.get(
            tuple(argv),
            RunResult(
                stdout="", stderr="", exit_code=1,
                rejected=False, reject_reason=None, argv=argv,
                command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
            ),
        )


def _runresult(stdout="", stderr="", exit_code=0, rejected=False) -> RunResult:
    return RunResult(
        stdout=stdout, stderr=stderr, exit_code=exit_code,
        rejected=rejected, reject_reason="policy" if rejected else None,
        argv=[], command_sha256="0" * 64, duration_ms=0.0,
        bytes_out=len(stdout.encode()),
    )


def _ctx(executor, profile_name="linux"):
    profile = materialize_paths(LINUX, "/home/u")
    if profile_name != "linux":
        profile = profile.model_copy(update={"name": profile_name})
    return CheckContext(
        executor=executor, redactor=None, ioc_dir=Path("/tmp"),
        profile=profile, log_review_config=None,
    )


def _run(check, ctx):
    return asyncio.run(check.run(ctx))


def _stat_hit_at(path: str) -> RunResult:
    return _runresult(stdout=path + "\n")


def _stat_miss() -> RunResult:
    return _runresult(stderr="No such file", exit_code=1)


def _detect_responses(found_path: str | None) -> dict[tuple, RunResult]:
    """Stub the three iptables stat probes; return responses dict."""
    candidates = ("/usr/sbin/iptables", "/sbin/iptables", "/usr/bin/iptables")
    out: dict[tuple, RunResult] = {}
    for c in candidates:
        if c == found_path:
            out[("stat", "-c", "%n", c)] = _stat_hit_at(c)
        else:
            out[("stat", "-c", "%n", c)] = _stat_miss()
    return out


def test_non_linux_profile_not_applicable():
    findings = _run(IptablesAuditCheck(), _ctx(_RecordingExecutor({}), profile_name="macos"))
    assert findings == []


def test_iptables_not_installed_not_applicable():
    findings = _run(IptablesAuditCheck(), _ctx(_RecordingExecutor(_detect_responses(None))))
    assert findings == []


def test_compliant_default_drop_zero_findings():
    v4 = (_FIXTURES / "iptables_default_drop_v4.txt").read_text()
    v6 = (_FIXTURES / "iptables_default_drop_v4.txt").read_text()
    responses = _detect_responses(_BIN)
    responses[_IPTABLES_S] = _runresult(stdout=v4)
    responses[_IP6TABLES_S] = _runresult(stdout=v6)
    findings = _run(IptablesAuditCheck(), _ctx(_RecordingExecutor(responses)))
    assert findings == []


def test_default_accept_v4_emits_two_findings():
    v4 = (_FIXTURES / "iptables_default_accept_v4.txt").read_text()
    v6 = (_FIXTURES / "iptables_default_accept_v4.txt").read_text()  # same shape
    responses = _detect_responses(_BIN)
    responses[_IPTABLES_S] = _runresult(stdout=v4)
    responses[_IP6TABLES_S] = _runresult(stdout=v6)
    findings = _run(IptablesAuditCheck(), _ctx(_RecordingExecutor(responses)))
    # v4 INPUT (critical) + v4 FORWARD (high) + v6 INPUT (critical) + v6 FORWARD (critical) = 4
    assert len(findings) == 4
    sev_count = {"critical": 0, "high": 0}
    for f in findings:
        sev_count[f.severity] = sev_count.get(f.severity, 0) + 1
    assert sev_count["critical"] == 3  # v4 INPUT, v6 INPUT, v6 FORWARD
    assert sev_count["high"] == 1       # v4 FORWARD


def test_ufw_chains_detected_not_applicable():
    text = (_FIXTURES / "iptables_with_ufw_chains.txt").read_text()
    responses = _detect_responses(_BIN)
    responses[_IPTABLES_S] = _runresult(stdout=text)
    responses[_IP6TABLES_S] = _runresult(stdout=text)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(IptablesAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_not_applicable is True
    assert "ufw" in (ctx.status.not_applicable_reason or "").lower()


def test_command_rejected_skipped():
    responses = _detect_responses(_BIN)
    responses[_IPTABLES_S] = _runresult(rejected=True)
    responses[_IP6TABLES_S] = _runresult(rejected=True)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(IptablesAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is True


def test_permission_denied_skipped():
    responses = _detect_responses(_BIN)
    responses[_IPTABLES_S] = _runresult(stderr="iptables: Permission denied", exit_code=4)
    responses[_IP6TABLES_S] = _runresult(stderr="iptables: Permission denied", exit_code=4)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(IptablesAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is True
    assert "sudoers" in (ctx.status.skip_reason or "").lower() or \
           "root" in (ctx.status.skip_reason or "").lower()


def test_v4_ok_v6_failed_emits_v4_only():
    v4 = (_FIXTURES / "iptables_default_accept_v4.txt").read_text()
    responses = _detect_responses(_BIN)
    responses[_IPTABLES_S] = _runresult(stdout=v4)
    responses[_IP6TABLES_S] = _runresult(exit_code=1, stderr="ip6tables: not found")
    findings = _run(IptablesAuditCheck(), _ctx(_RecordingExecutor(responses)))
    # v4 only: 1 critical INPUT + 1 high FORWARD = 2
    assert len(findings) == 2
    assert all(f.evidence["family"] == "ipv4" for f in findings)
```

- [ ] **Step 3: Run tests to verify they fail with import error**

Run: `.venv/bin/pytest tests/test_iptables_audit.py -q`
Expected: All tests fail with `ModuleNotFoundError: agentsec.audit.checks.iptables_audit`.

- [ ] **Step 4: Implement `IptablesAuditCheck`**

Create `src/agentsec/audit/checks/iptables_audit.py`:

```python
"""iptables / ip6tables 默认策略审计（Phase 7e.2）。

Pulls 'iptables -S' + 'ip6tables -S' and emits one Finding per chain
(INPUT/FORWARD) whose default policy is neither DROP nor REJECT.
v4 / v6 are independent: v6 failure does not poison v4 reporting and
vice versa. Whole-Check status is set only if BOTH families failed.
"""

from __future__ import annotations

import hashlib

from ..findings import Finding
from ._firewall_common import detect_iptables, output_managed_by_ufw
from .base import Check, CheckContext

_PERM_DENIED_SUBSTRINGS = (
    "permission denied",
    "operation not permitted",
    "must be root",
    "you need to be root",
)

# Sentinel return values from _evaluate_family — used only inside this module.
_SKIPPED = object()
_PERM_DENIED = object()
_UFW_TAKEOVER = object()


def _looks_like_perm_denied(stderr: str) -> bool:
    s = stderr.lower()
    return any(needle in s for needle in _PERM_DENIED_SUBSTRINGS)


def parse_iptables_default_policies(stdout: str) -> dict[str, str]:
    """从 'iptables -S' 输出抓 -P <CHAIN> <POLICY> 行。"""
    out: dict[str, str] = {}
    for line in stdout.splitlines():
        line = line.strip()
        if not line.startswith("-P "):
            continue
        parts = line.split()
        if len(parts) >= 3:
            out[parts[1]] = parts[2]
    return out


class IptablesAuditCheck(Check):
    id = "iptables_audit"

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(
                f"{self.id}：当前 profile {ctx.profile.name!r} 不是 Linux"
            )
            return []
        if detect_iptables(ctx) is None:
            ctx.status.not_applicable(
                f"{self.id}：被审主机未检出 iptables 二进制"
            )
            return []

        v4_result = ctx.executor.run(["iptables",  "-S"])
        v6_result = ctx.executor.run(["ip6tables", "-S"])

        v4_findings = self._evaluate_family(v4_result, family="ipv4")
        v6_findings = self._evaluate_family(v6_result, family="ipv6")

        # ufw 接管（任一 family 命中即整体 not_applicable，避免双报）。
        # Note: corner case — ufw with IPV6=no would leave v6 unmanaged.
        # 7e.2 takes the simpler approach (defer entirely); revisit in 7e.5.
        if v4_findings is _UFW_TAKEOVER or v6_findings is _UFW_TAKEOVER:
            ctx.status.not_applicable(
                f"{self.id}：检出 ufw 注入链，由 ufw_audit 评估"
            )
            return []

        v4_ok = isinstance(v4_findings, list)
        v6_ok = isinstance(v6_findings, list)

        # If neither family produced parseable output → skipped.
        if not v4_ok and not v6_ok:
            if v4_findings is _PERM_DENIED or v6_findings is _PERM_DENIED:
                ctx.status.skipped(
                    f"{self.id}：权限不足，需 root 或 NOPASSWD sudoers "
                    "（在该主机为执行用户配置 NOPASSWD sudoers，"
                    "或以 root 身份运行 agentsec server-audit）"
                )
            else:
                reason = (
                    self._skip_reason(v4_result)
                    or self._skip_reason(v6_result)
                    or f"{self.id} 命令全部失败"
                )
                ctx.status.skipped(reason)
            return []

        result: list[Finding] = []
        if v4_ok:
            result.extend(v4_findings)
        if v6_ok:
            result.extend(v6_findings)
        return result

    def _skip_reason(self, r) -> str | None:
        if r.rejected:
            return f"{self.id}：iptables 命令被策略拒绝 ({r.reject_reason})"
        if _looks_like_perm_denied(r.stderr):
            return (
                f"{self.id}：权限不足，需 root 或 NOPASSWD sudoers "
                "（在该主机为执行用户配置 NOPASSWD sudoers，"
                "或以 root 身份运行 agentsec server-audit）"
            )
        if r.exit_code != 0:
            return f"{self.id}：iptables 退出码 {r.exit_code}"
        return None

    def _evaluate_family(self, result, *, family: str):
        if result.rejected:
            return _SKIPPED
        if result.exit_code != 0:
            if _looks_like_perm_denied(result.stderr):
                return _PERM_DENIED
            return _SKIPPED
        if not result.stdout.strip():
            return _SKIPPED
        if output_managed_by_ufw(result.stdout, backend="iptables"):
            return _UFW_TAKEOVER
        policies = parse_iptables_default_policies(result.stdout)
        findings: list[Finding] = []
        dump_sha = hashlib.sha256(result.stdout.encode("utf-8")).hexdigest()
        for chain in ("INPUT", "FORWARD"):
            policy = policies.get(chain)
            if policy is None:
                continue
            if policy in ("DROP", "REJECT"):
                continue
            severity = "critical" if (chain == "INPUT" or family == "ipv6") else "high"
            findings.append(Finding(
                check=self.id, severity=severity,
                title=(
                    f"iptables {family} {chain} 默认策略为 {policy}（应为 DROP/REJECT）"
                ),
                description=(
                    "默认接收策略 / 后端未激活 / IPv6 平行漏洞 — "
                    "CIS Linux 加固基线"
                ),
                evidence={
                    "family": family, "chain": chain, "policy": policy,
                    "dump_sha256": dump_sha,
                },
                remediation=(
                    "设置 INPUT/FORWARD 默认策略为 DROP/REJECT；"
                    "如使用 ufw / nftables 前端，请通过对应工具配置"
                ),
                threat_refs=["TI-LINUX-CIS-FIREWALL"],
            ))
        return findings
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/test_iptables_audit.py -q`
Expected: All 8 tests pass.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/checks/iptables_audit.py \
        tests/test_iptables_audit.py \
        tests/fixtures/firewall/iptables_default_drop_v4.txt \
        tests/fixtures/firewall/iptables_default_accept_v4.txt \
        tests/fixtures/firewall/iptables_with_ufw_chains.txt \
        tests/fixtures/firewall/ip6tables_default_accept.txt
git commit -m "$(cat <<'EOF'
feat(7e.2): IptablesAuditCheck — default policy + IPv6 parallel

Reads iptables -S and ip6tables -S; emits a Finding for any INPUT or
FORWARD chain whose default policy is not DROP/REJECT. IPv4/IPv6 are
evaluated independently. Defers to ufw_audit when ufw-injected chains
are detected. Permission denied → skipped with NOPASSWD remediation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: `NftablesAuditCheck`

**Files:**
- Create: `src/agentsec/audit/checks/nftables_audit.py`
- Test: `tests/test_nftables_audit.py`
- Test fixtures: `tests/fixtures/firewall/nft_ruleset_drop.json`, `nft_ruleset_accept.json`, `nft_ruleset_with_ufw.json`

- [ ] **Step 1: Write fixtures**

Create `tests/fixtures/firewall/nft_ruleset_drop.json`:

```json
{"nftables":[{"table":{"family":"inet","name":"filter","handle":1}},{"chain":{"family":"inet","table":"filter","name":"input","handle":1,"type":"filter","hook":"input","prio":0,"policy":"drop"}},{"chain":{"family":"inet","table":"filter","name":"forward","handle":2,"type":"filter","hook":"forward","prio":0,"policy":"drop"}},{"chain":{"family":"inet","table":"filter","name":"output","handle":3,"type":"filter","hook":"output","prio":0,"policy":"accept"}}]}
```

Create `tests/fixtures/firewall/nft_ruleset_accept.json`:

```json
{"nftables":[{"table":{"family":"inet","name":"filter","handle":1}},{"chain":{"family":"inet","table":"filter","name":"input","handle":1,"type":"filter","hook":"input","prio":0,"policy":"accept"}},{"chain":{"family":"inet","table":"filter","name":"forward","handle":2,"type":"filter","hook":"forward","prio":0,"policy":"accept"}}]}
```

Create `tests/fixtures/firewall/nft_ruleset_with_ufw.json`:

```json
{"nftables":[{"table":{"family":"inet","name":"ufw","handle":1}},{"chain":{"family":"inet","table":"ufw","name":"input","handle":1,"type":"filter","hook":"input","prio":-100,"policy":"accept"}}]}
```

- [ ] **Step 2: Write the failing test file**

Create `tests/test_nftables_audit.py`:

```python
"""NftablesAuditCheck unit tests (Phase 7e.2)."""

from __future__ import annotations

import asyncio
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.nftables_audit import NftablesAuditCheck
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult


_FIXTURES = Path(__file__).parent / "fixtures" / "firewall"
_BIN = "/usr/sbin/nft"
_NFT_LIST = ("nft", "-j", "list", "ruleset")


class _RecordingExecutor:
    def __init__(self, responses):
        self._responses = responses
        self.calls = []

    def run(self, argv, *, role=None):
        argv = list(argv)
        self.calls.append(argv)
        return self._responses.get(
            tuple(argv),
            RunResult(
                stdout="", stderr="", exit_code=1, rejected=False,
                reject_reason=None, argv=argv, command_sha256="0" * 64,
                duration_ms=0.0, bytes_out=0,
            ),
        )


def _runresult(stdout="", stderr="", exit_code=0, rejected=False):
    return RunResult(
        stdout=stdout, stderr=stderr, exit_code=exit_code,
        rejected=rejected, reject_reason="policy" if rejected else None,
        argv=[], command_sha256="0" * 64, duration_ms=0.0,
        bytes_out=len(stdout.encode()),
    )


def _ctx(executor, profile_name="linux"):
    profile = materialize_paths(LINUX, "/home/u")
    if profile_name != "linux":
        profile = profile.model_copy(update={"name": profile_name})
    return CheckContext(executor=executor, redactor=None, ioc_dir=Path("/tmp"),
                        profile=profile, log_review_config=None)


def _run(check, ctx):
    return asyncio.run(check.run(ctx))


def _detect_responses(found_path):
    out = {}
    for c in ("/usr/sbin/nft", "/sbin/nft", "/usr/bin/nft"):
        if c == found_path:
            out[("stat", "-c", "%n", c)] = _runresult(stdout=c + "\n")
        else:
            out[("stat", "-c", "%n", c)] = _runresult(stderr="No such file", exit_code=1)
    return out


def test_non_linux_profile_not_applicable():
    findings = _run(NftablesAuditCheck(), _ctx(_RecordingExecutor({}), profile_name="macos"))
    assert findings == []


def test_nft_not_installed_not_applicable():
    findings = _run(NftablesAuditCheck(), _ctx(_RecordingExecutor(_detect_responses(None))))
    assert findings == []


def test_compliant_drop_policy_zero_findings():
    text = (_FIXTURES / "nft_ruleset_drop.json").read_text()
    responses = _detect_responses(_BIN)
    responses[_NFT_LIST] = _runresult(stdout=text)
    findings = _run(NftablesAuditCheck(), _ctx(_RecordingExecutor(responses)))
    assert findings == []


def test_accept_policy_emits_findings():
    text = (_FIXTURES / "nft_ruleset_accept.json").read_text()
    responses = _detect_responses(_BIN)
    responses[_NFT_LIST] = _runresult(stdout=text)
    findings = _run(NftablesAuditCheck(), _ctx(_RecordingExecutor(responses)))
    # input (critical) + forward (high) = 2
    assert len(findings) == 2
    severities = sorted(f.severity for f in findings)
    assert severities == ["critical", "high"]


def test_empty_ruleset_emits_one_critical():
    responses = _detect_responses(_BIN)
    responses[_NFT_LIST] = _runresult(stdout='{"nftables":[]}')
    findings = _run(NftablesAuditCheck(), _ctx(_RecordingExecutor(responses)))
    assert len(findings) == 1
    assert findings[0].severity == "critical"


def test_truly_empty_stdout_skipped():
    responses = _detect_responses(_BIN)
    responses[_NFT_LIST] = _runresult(stdout="")
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(NftablesAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is True


def test_invalid_json_skipped():
    responses = _detect_responses(_BIN)
    responses[_NFT_LIST] = _runresult(stdout="not-json{{{")
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(NftablesAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is True


def test_ufw_table_detected_not_applicable():
    text = (_FIXTURES / "nft_ruleset_with_ufw.json").read_text()
    responses = _detect_responses(_BIN)
    responses[_NFT_LIST] = _runresult(stdout=text)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(NftablesAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_not_applicable is True


def test_command_rejected_skipped():
    responses = _detect_responses(_BIN)
    responses[_NFT_LIST] = _runresult(rejected=True)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(NftablesAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is True


def test_permission_denied_skipped():
    responses = _detect_responses(_BIN)
    responses[_NFT_LIST] = _runresult(stderr="Operation not permitted", exit_code=1)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(NftablesAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is True
    assert "root" in (ctx.status.skip_reason or "").lower() or \
           "sudoers" in (ctx.status.skip_reason or "").lower()
```

- [ ] **Step 3: Run tests to verify they fail with import error**

Run: `.venv/bin/pytest tests/test_nftables_audit.py -q`
Expected: All tests fail with `ModuleNotFoundError`.

- [ ] **Step 4: Implement `NftablesAuditCheck`**

Create `src/agentsec/audit/checks/nftables_audit.py`:

```python
"""nftables 默认策略审计（Phase 7e.2）。

Pulls 'nft -j list ruleset' (JSON) and emits one Finding per hooked
chain (hook=input/forward) whose policy is neither 'drop' nor 'reject'.
Empty ruleset → 1 critical finding (nft installed but unused).
"""

from __future__ import annotations

import hashlib
import json

from ..findings import Finding
from ._firewall_common import detect_nft, output_managed_by_ufw
from .base import Check, CheckContext

_PERM_DENIED_SUBSTRINGS = (
    "permission denied",
    "operation not permitted",
    "must be root",
    "you need to be root",
)


def _looks_like_perm_denied(stderr: str) -> bool:
    s = stderr.lower()
    return any(needle in s for needle in _PERM_DENIED_SUBSTRINGS)


class NftablesAuditCheck(Check):
    id = "nftables_audit"

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(
                f"{self.id}：当前 profile {ctx.profile.name!r} 不是 Linux"
            )
            return []
        if detect_nft(ctx) is None:
            ctx.status.not_applicable(
                f"{self.id}：被审主机未检出 nft 二进制"
            )
            return []

        result = ctx.executor.run(["nft", "-j", "list", "ruleset"])

        if result.rejected:
            ctx.status.skipped(
                f"{self.id}：nft 命令被策略拒绝 ({result.reject_reason})"
            )
            return []
        if result.exit_code != 0:
            if _looks_like_perm_denied(result.stderr):
                ctx.status.skipped(
                    f"{self.id}：权限不足，需 root 或 NOPASSWD sudoers"
                )
            else:
                ctx.status.skipped(
                    f"{self.id}：nft 退出码 {result.exit_code}"
                )
            return []
        if not result.stdout.strip():
            ctx.status.skipped(f"{self.id}：nft 输出为空")
            return []

        if output_managed_by_ufw(result.stdout, backend="nftables"):
            ctx.status.not_applicable(
                f"{self.id}：检出 ufw 表，由 ufw_audit 评估"
            )
            return []

        try:
            parsed = json.loads(result.stdout)
        except json.JSONDecodeError:
            ctx.status.skipped(f"{self.id}：nft -j 输出非 JSON")
            return []

        items = parsed.get("nftables", [])
        ruleset_sha = hashlib.sha256(result.stdout.encode("utf-8")).hexdigest()

        if not items:
            return [Finding(
                check=self.id, severity="critical",
                title="nftables ruleset 为空且 nft 已安装，等同未启用过滤",
                description=(
                    "默认接收策略 / 后端未激活 / IPv6 平行漏洞 — "
                    "CIS Linux 加固基线"
                ),
                evidence={"ruleset_sha256": ruleset_sha},
                remediation=(
                    "装载 nftables 规则集并 enable nftables 服务；或改用 ufw"
                ),
                threat_refs=["TI-LINUX-CIS-FIREWALL"],
            )]

        findings: list[Finding] = []
        for entry in items:
            chain = entry.get("chain")
            if not chain:
                continue
            hook = chain.get("hook")
            if hook not in ("input", "forward"):
                continue
            policy = chain.get("policy")
            if policy in ("drop", "reject"):
                continue
            family = chain.get("family", "?")
            table = chain.get("table", "?")
            name = chain.get("name", "?")
            severity = "critical" if hook == "input" else "high"
            findings.append(Finding(
                check=self.id, severity=severity,
                title=(
                    f"nftables {family} 表 {table} 链 {name} (hook={hook}) "
                    f"默认策略为 {policy}"
                ),
                description=(
                    "默认接收策略 / 后端未激活 / IPv6 平行漏洞 — "
                    "CIS Linux 加固基线"
                ),
                evidence={
                    "family": family, "table": table, "chain": name,
                    "hook": hook, "policy": policy,
                    "ruleset_sha256": ruleset_sha,
                },
                remediation=(
                    "将 hooked chain 默认策略改为 drop/reject；"
                    "或显式 add rule … reject"
                ),
                threat_refs=["TI-LINUX-CIS-FIREWALL"],
            ))
        return findings
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/test_nftables_audit.py -q`
Expected: All 10 tests pass.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/checks/nftables_audit.py \
        tests/test_nftables_audit.py \
        tests/fixtures/firewall/nft_ruleset_drop.json \
        tests/fixtures/firewall/nft_ruleset_accept.json \
        tests/fixtures/firewall/nft_ruleset_with_ufw.json
git commit -m "$(cat <<'EOF'
feat(7e.2): NftablesAuditCheck — JSON ruleset hooked-chain audit

Parses 'nft -j list ruleset' JSON; emits a Finding per hooked chain
(hook=input/forward) whose policy is not drop/reject. Empty ruleset
on installed nft is itself a critical finding. Defers to ufw_audit
when an inet ufw table is present.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: `UfwAuditCheck`

**Files:**
- Create: `src/agentsec/audit/checks/ufw_audit.py`
- Test: `tests/test_ufw_audit.py`
- Test fixtures: `tests/fixtures/firewall/ufw_active_default_deny.txt`, `ufw_active_default_allow.txt`, `ufw_inactive.txt`

- [ ] **Step 1: Write fixtures**

Create `tests/fixtures/firewall/ufw_active_default_deny.txt`:

```
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW IN    Anywhere
```

Create `tests/fixtures/firewall/ufw_active_default_allow.txt`:

```
Status: active
Logging: on (low)
Default: allow (incoming), allow (outgoing), allow (routed)
New profiles: skip
```

Create `tests/fixtures/firewall/ufw_inactive.txt`:

```
Status: inactive
```

- [ ] **Step 2: Write the failing test file**

Create `tests/test_ufw_audit.py`:

```python
"""UfwAuditCheck unit tests (Phase 7e.2)."""

from __future__ import annotations

import asyncio
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.ufw_audit import UfwAuditCheck
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult


_FIXTURES = Path(__file__).parent / "fixtures" / "firewall"
_BIN = "/usr/sbin/ufw"
_UFW_STATUS = ("ufw", "status", "verbose")


class _RecordingExecutor:
    def __init__(self, responses):
        self._responses = responses
        self.calls = []

    def run(self, argv, *, role=None):
        argv = list(argv)
        self.calls.append(argv)
        return self._responses.get(
            tuple(argv),
            RunResult(
                stdout="", stderr="", exit_code=1, rejected=False,
                reject_reason=None, argv=argv, command_sha256="0" * 64,
                duration_ms=0.0, bytes_out=0,
            ),
        )


def _runresult(stdout="", stderr="", exit_code=0, rejected=False):
    return RunResult(
        stdout=stdout, stderr=stderr, exit_code=exit_code,
        rejected=rejected, reject_reason="policy" if rejected else None,
        argv=[], command_sha256="0" * 64, duration_ms=0.0,
        bytes_out=len(stdout.encode()),
    )


def _ctx(executor, profile_name="linux"):
    profile = materialize_paths(LINUX, "/home/u")
    if profile_name != "linux":
        profile = profile.model_copy(update={"name": profile_name})
    return CheckContext(executor=executor, redactor=None, ioc_dir=Path("/tmp"),
                        profile=profile, log_review_config=None)


def _run(check, ctx):
    return asyncio.run(check.run(ctx))


def _detect_responses(found_path):
    out = {}
    for c in ("/usr/sbin/ufw", "/sbin/ufw", "/usr/bin/ufw"):
        if c == found_path:
            out[("stat", "-c", "%n", c)] = _runresult(stdout=c + "\n")
        else:
            out[("stat", "-c", "%n", c)] = _runresult(stderr="No such file", exit_code=1)
    return out


def test_non_linux_profile_not_applicable():
    findings = _run(UfwAuditCheck(), _ctx(_RecordingExecutor({}), profile_name="macos"))
    assert findings == []


def test_ufw_not_installed_not_applicable():
    findings = _run(UfwAuditCheck(), _ctx(_RecordingExecutor(_detect_responses(None))))
    assert findings == []


def test_compliant_active_deny_zero_findings():
    text = (_FIXTURES / "ufw_active_default_deny.txt").read_text()
    responses = _detect_responses(_BIN)
    responses[_UFW_STATUS] = _runresult(stdout=text)
    findings = _run(UfwAuditCheck(), _ctx(_RecordingExecutor(responses)))
    assert findings == []


def test_inactive_emits_one_critical():
    text = (_FIXTURES / "ufw_inactive.txt").read_text()
    responses = _detect_responses(_BIN)
    responses[_UFW_STATUS] = _runresult(stdout=text)
    findings = _run(UfwAuditCheck(), _ctx(_RecordingExecutor(responses)))
    assert len(findings) == 1
    assert findings[0].severity == "critical"
    assert "未启用" in findings[0].title or "inactive" in findings[0].title.lower()


def test_active_default_allow_emits_critical_and_high():
    text = (_FIXTURES / "ufw_active_default_allow.txt").read_text()
    responses = _detect_responses(_BIN)
    responses[_UFW_STATUS] = _runresult(stdout=text)
    findings = _run(UfwAuditCheck(), _ctx(_RecordingExecutor(responses)))
    assert len(findings) == 2
    severities = sorted(f.severity for f in findings)
    assert severities == ["critical", "high"]


def test_command_rejected_skipped():
    responses = _detect_responses(_BIN)
    responses[_UFW_STATUS] = _runresult(rejected=True)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(UfwAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is True


def test_permission_denied_skipped():
    responses = _detect_responses(_BIN)
    responses[_UFW_STATUS] = _runresult(stderr="ERROR: You need to be root", exit_code=1)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(UfwAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is True
    assert "root" in (ctx.status.skip_reason or "").lower() or \
           "sudoers" in (ctx.status.skip_reason or "").lower()


def test_empty_stdout_skipped():
    responses = _detect_responses(_BIN)
    responses[_UFW_STATUS] = _runresult(stdout="")
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(UfwAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is True
```

- [ ] **Step 3: Run tests to verify they fail with import error**

Run: `.venv/bin/pytest tests/test_ufw_audit.py -q`
Expected: All tests fail with `ModuleNotFoundError`.

- [ ] **Step 4: Implement `UfwAuditCheck`**

Create `src/agentsec/audit/checks/ufw_audit.py`:

```python
"""ufw 默认策略审计（Phase 7e.2）。

Pulls 'ufw status verbose'; emits 1 critical when inactive, otherwise
inspects the 'Default:' line and emits findings for non-deny incoming
(critical) and non-deny forward (high). IPv6 detection is deferred to
7e.5 because 'ufw status verbose' does not surface that field.
"""

from __future__ import annotations

import re

from ..findings import Finding
from ._firewall_common import detect_ufw
from .base import Check, CheckContext

_PERM_DENIED_SUBSTRINGS = (
    "permission denied",
    "operation not permitted",
    "must be root",
    "you need to be root",
)

_DEFAULT_LINE = re.compile(
    r"^Default:\s*"
    r"(?P<incoming>\S+)\s*\(incoming\)"
    r",\s*(?P<outgoing>\S+)\s*\(outgoing\)"
    r"(?:,\s*(?P<routed>\S+)\s*\(routed\))?",
    re.M,
)


def _looks_like_perm_denied(stderr: str) -> bool:
    s = stderr.lower()
    return any(needle in s for needle in _PERM_DENIED_SUBSTRINGS)


def parse_ufw_status(stdout: str) -> dict:
    """Returns {'status': 'active'|'inactive'|'unknown', 'incoming': str|None,
       'outgoing': str|None, 'routed': str|None}."""
    out = {"status": "unknown", "incoming": None, "outgoing": None, "routed": None}
    for line in stdout.splitlines():
        s = line.strip()
        if s.lower().startswith("status:"):
            value = s.split(":", 1)[1].strip().lower()
            if value in ("active", "inactive"):
                out["status"] = value
    m = _DEFAULT_LINE.search(stdout)
    if m:
        out["incoming"] = m.group("incoming")
        out["outgoing"] = m.group("outgoing")
        out["routed"] = m.group("routed")
    return out


class UfwAuditCheck(Check):
    id = "ufw_audit"

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(
                f"{self.id}：当前 profile {ctx.profile.name!r} 不是 Linux"
            )
            return []
        if detect_ufw(ctx) is None:
            ctx.status.not_applicable(
                f"{self.id}：被审主机未检出 ufw 二进制"
            )
            return []

        result = ctx.executor.run(["ufw", "status", "verbose"])

        if result.rejected:
            ctx.status.skipped(
                f"{self.id}：ufw 命令被策略拒绝 ({result.reject_reason})"
            )
            return []
        if result.exit_code != 0:
            if _looks_like_perm_denied(result.stderr):
                ctx.status.skipped(
                    f"{self.id}：权限不足，需 root 或 NOPASSWD sudoers"
                )
            else:
                ctx.status.skipped(f"{self.id}：ufw 退出码 {result.exit_code}")
            return []
        if not result.stdout.strip():
            ctx.status.skipped(f"{self.id}：ufw status 输出为空")
            return []

        parsed = parse_ufw_status(result.stdout)
        findings: list[Finding] = []

        if parsed["status"] == "inactive":
            findings.append(Finding(
                check=self.id, severity="critical",
                title="ufw 已安装但未启用",
                description=(
                    "默认接收策略 / 后端未激活 / IPv6 平行漏洞 — "
                    "CIS Linux 加固基线"
                ),
                evidence={
                    "status": "inactive",
                    "default_incoming": parsed["incoming"],
                    "default_forward": parsed["routed"],
                    "default_outgoing": parsed["outgoing"],
                },
                remediation="启用 ufw：`sudo ufw enable`；事先确认默认策略为 deny incoming",
                threat_refs=["TI-LINUX-CIS-FIREWALL"],
            ))
            return findings

        if parsed["status"] != "active":
            ctx.status.skipped(f"{self.id}：无法解析 'Status:' 字段")
            return []

        evidence = {
            "status": "active",
            "default_incoming": parsed["incoming"],
            "default_forward": parsed["routed"],
            "default_outgoing": parsed["outgoing"],
        }
        if parsed["incoming"] and parsed["incoming"].lower() not in ("deny", "reject"):
            findings.append(Finding(
                check=self.id, severity="critical",
                title="ufw 启用但默认 incoming 策略非 deny/reject",
                description="默认接收策略 — CIS Linux 加固基线",
                evidence=evidence,
                remediation="`sudo ufw default deny incoming`",
                threat_refs=["TI-LINUX-CIS-FIREWALL"],
            ))
        if parsed["routed"] and parsed["routed"].lower() not in ("deny", "reject", "disabled"):
            findings.append(Finding(
                check=self.id, severity="high",
                title="ufw 启用但默认 forward(routed) 策略非 deny/reject/disabled",
                description="默认接收策略 — CIS Linux 加固基线",
                evidence=evidence,
                remediation="`sudo ufw default deny routed`",
                threat_refs=["TI-LINUX-CIS-FIREWALL"],
            ))
        return findings
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `.venv/bin/pytest tests/test_ufw_audit.py -q`
Expected: All 8 tests pass.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/checks/ufw_audit.py \
        tests/test_ufw_audit.py \
        tests/fixtures/firewall/ufw_active_default_deny.txt \
        tests/fixtures/firewall/ufw_active_default_allow.txt \
        tests/fixtures/firewall/ufw_inactive.txt
git commit -m "$(cat <<'EOF'
feat(7e.2): UfwAuditCheck — inactive + default-allow detection

Parses 'ufw status verbose'; emits critical when inactive, otherwise
parses the 'Default:' line and reports default-incoming != deny
(critical) and default-routed != deny/disabled (high). IPv6 deferred
to 7e.5 (ufw status verbose does not surface that field).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: CLI Registration

**Files:**
- Modify: `src/agentsec/cli.py` — 3 imports + 3 registrations × 2 sites

- [ ] **Step 1: Add imports**

Open `src/agentsec/cli.py`. Locate the existing import block (around lines 18–38) that contains:

```python
from .audit.checks.docker_image_cve import DockerImageCveCheck
from .audit.checks.docker_runtime_audit import DockerRuntimeAuditCheck
```

Immediately after the docker imports, insert:

```python
from .audit.checks.iptables_audit import IptablesAuditCheck
from .audit.checks.nftables_audit import NftablesAuditCheck
from .audit.checks.ufw_audit import UfwAuditCheck
```

- [ ] **Step 2: Register Checks in `server_audit` (~line 524)**

Locate the first registration block:

```python
        WorldWritableCheck(),
        AuditdRulesCheck(),
        SystemdServiceBlacklistCheck(),
        SystemdUnitLintCheck(),
        DockerDaemonAuditCheck(),
        DockerRuntimeAuditCheck(),
        DockerImageCveCheck(),
```

Insert the three new Checks **before** `DockerDaemonAuditCheck()`:

```python
        WorldWritableCheck(),
        AuditdRulesCheck(),
        SystemdServiceBlacklistCheck(),
        SystemdUnitLintCheck(),
        IptablesAuditCheck(),
        NftablesAuditCheck(),
        UfwAuditCheck(),
        DockerDaemonAuditCheck(),
        DockerRuntimeAuditCheck(),
        DockerImageCveCheck(),
```

- [ ] **Step 3: Register Checks in `_evaluate_server` (~line 823)**

Locate the second registration block (identical shape, different line number) and apply the same insertion.

- [ ] **Step 4: Run lint + targeted CLI tests + full suite**

Run in parallel:
```
.venv/bin/ruff check src/agentsec/cli.py
.venv/bin/pyright src/agentsec/cli.py
.venv/bin/pytest tests/ -q
```
Expected: ruff clean; pyright 0 errors; all tests pass (including the new firewall tests added in Tasks 2–5).

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/cli.py
git commit -m "$(cat <<'EOF'
cli(7e.2): register IptablesAudit / NftablesAudit / UfwAudit (3 checks)

Wires the three new Phase 7e.2 firewall Checks into both the
server_audit subcommand and the evaluate orchestrator. Insertion order
preserves Phase timeline (7e.1 → 7e.2 → 7f).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: Mock Integration Test + Sample Report Refresh

**Files:**
- Modify: `tests/integration/test_openclaw_mock.py` — extend SSH stub for 9 firewall stat probes
- Modify: `docs/samples/report-2026-04-28/*` — refreshed via env var

- [ ] **Step 1: Find the mock SSH responses table**

Run: `grep -n "stat -c %n" tests/integration/test_openclaw_mock.py`
Expected: locates the response table where existing stat probes (sysctl etc.) are stubbed. Note the surrounding helper signature (likely a dict from `tuple` → `RunResult` similar to the unit-test pattern).

- [ ] **Step 2: Add 9 firewall stat-miss responses to the mock**

In the mock SSH response dict (or the closest existing `_stat_response` helper), add stub responses that return `exit_code=1, stderr="No such file"` for each of:

```
("stat", "-c", "%n", "/usr/sbin/iptables")
("stat", "-c", "%n", "/sbin/iptables")
("stat", "-c", "%n", "/usr/bin/iptables")
("stat", "-c", "%n", "/usr/sbin/nft")
("stat", "-c", "%n", "/sbin/nft")
("stat", "-c", "%n", "/usr/bin/nft")
("stat", "-c", "%n", "/usr/sbin/ufw")
("stat", "-c", "%n", "/sbin/ufw")
("stat", "-c", "%n", "/usr/bin/ufw")
```

The existing pattern (look at how docker `/usr/bin/docker` etc. is stubbed in `test_openclaw_mock.py`) is the template — copy it for the firewall paths. The result: 3 firewall Checks all land `not_applicable("被审主机未检出 ... 二进制")`, contribute 0 findings.

- [ ] **Step 3: Run the mock integration test once without refresh to confirm a controlled drift**

Run: `.venv/bin/pytest tests/integration/test_openclaw_mock.py::test_openclaw_mock_end_to_end -v`
Expected: Test fails with a sample-drift assertion error mentioning the new check anchors (`<!-- check:iptables_audit -->`, `<!-- check:nftables_audit -->`, `<!-- check:ufw_audit -->`). This is the expected sentinel behavior — the test proves the new Checks ran but the committed sample is stale.

- [ ] **Step 4: Refresh the committed sample report**

Run: `AGENTSEC_REFRESH_SAMPLE=1 .venv/bin/pytest tests/integration/test_openclaw_mock.py::test_openclaw_mock_end_to_end -v`
Expected: Test passes; `docs/samples/report-2026-04-28/*.md` files are updated in place to include the 3 new check anchors as `not_applicable` cards.

- [ ] **Step 5: Re-run the mock test to confirm drift guard now passes**

Run: `.venv/bin/pytest tests/integration/test_openclaw_mock.py::test_openclaw_mock_end_to_end -v`
Expected: PASS.

- [ ] **Step 6: Run full suite**

Run: `.venv/bin/pytest tests/ -q`
Expected: All ≈1120 tests pass.

- [ ] **Step 7: Commit**

```bash
git add tests/integration/test_openclaw_mock.py docs/samples/report-2026-04-28/
git commit -m "$(cat <<'EOF'
test(7e.2): mock SSH stub + sample report refresh for 3 new checks

Adds 9 firewall stat-probe miss responses so iptables_audit /
nftables_audit / ufw_audit land not_applicable in the mock fixture.
Refreshes the committed sample report to include the 3 new check
anchors so the drift guard stays green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: ADR-0011

**Files:**
- Create: `docs/adr/0011-phase-7e2-firewall-audit.md`

- [ ] **Step 1: Find the existing ADR template + ADR-0009 (peer)**

Run: `ls docs/adr/_template.md docs/adr/0009-*.md`
Expected: lists `_template.md` and `0009-linux-host-hardening-extras.md`. Read 0009 to mirror its structure.

- [ ] **Step 2: Write ADR-0011**

Create `docs/adr/0011-phase-7e2-firewall-audit.md`:

```markdown
# ADR-0011: Phase 7e.2 — Firewall audit (iptables / nftables / ufw)

**Status:** Accepted
**Date:** 2026-05-09
**Related:** ADR-0008 (Phase 7e), ADR-0009 (Phase 7e.1), ADR-0010 (Phase 7f)

## Context

Phase 7e.1 deferred firewall coverage to a dedicated phase because three
backends multiply the implementation surface (~equal to 7e itself).
This ADR records the design decisions for Phase 7e.2 — the spec lives
at `docs/superpowers/specs/2026-05-09-phase-7e2-firewall-design.md`.

## Decisions

### D1. Backend coverage = iptables + nftables + ufw

Cover the three backends that account for the vast majority of Linux
server deployments (Ubuntu/Debian/RHEL/Alpine). Defer firewalld
(CentOS/RHEL D-Bus + zone model) to Phase 7e.3 and macOS pf to 7e.4.

### D2. Per-backend Check granularity (3 Checks)

`iptables_audit` / `nftables_audit` / `ufw_audit`, not a single
`firewall_audit` with internal dispatch. Mirrors the Phase 7f Docker
three-Check pattern (`docker_daemon_audit` / `docker_runtime_audit` /
`docker_image_cve`). Trade-off vs. one-Check: more files, but each
Check stays small and parser code is isolated.

### D3. Cross-talk avoidance — Approach A (independent Checks + ufw-chain helper)

On Ubuntu with ufw enabled, `iptables -S` shows ufw-injected `ufw-*`
chains and the INPUT default policy commonly stays ACCEPT (real DROP
happens at end of the ufw-* chain). Naïve independent evaluation would
double-report.

Chosen approach: each backend Check probes for ufw-injected chains in
its own output via the shared `output_managed_by_ufw(stdout, backend=)`
helper; on detection it reports `not_applicable("ufw 已接管…")` and
does not emit a default-policy finding. `ufw_audit` runs independently.

Rejected alternatives:

- **Backend resolver** (probe once at orchestrator startup, mark a
  primary backend on `CheckContext`): introduces shared state across
  Checks, breaks the per-Check autonomy invariant established in 7e/7f.
- **Pure independent (no cross-talk awareness)**: noisy double-reports
  on every Ubuntu+ufw host.

### D4. Best-effort permission model — no sudo policy

iptables / nft / ufw all require root on most distros. Rather than
extending `ssh_policy.yaml` to support `sudo -n <cmd>` (a cross-cutting
discussion that should not be bundled with this phase), each Check
detects EPERM via stderr substring and yields `skipped` with a clear
remediation message ("配置 NOPASSWD sudoers 或以 root 身份运行").
Operators who want active coverage add a NOPASSWD sudoers entry for
the specific allowed_argv tuples and re-run.

### D5. Audit point coverage — A + B + C only

This phase covers default policy (A), backend activation (B), and IPv6
parallel checks (C). Deferred to 7e.5: log rules (D), exposed-ACCEPT
to 0.0.0.0/0 (E), state tracking (F), ufw+raw cross-backend conflict
(G). Rationale: A+B+C catch the highest-density misconfigurations; D–G
either overlap with `exposure_scan` (E) or have higher false-positive
risk (F has many legitimate minimalist setups without it).

### D6. ufw IPv6 detection deferred to 7e.5

`ufw status verbose` does not surface IPv6 status — that lives in
`/etc/default/ufw` (`IPV6=yes/no`). Reading that file would require a
new `cat /etc/default/ufw` command-policy entry and changes the
"firewall checks read tool stdout, not config files" invariant. iptables
/ nftables Checks already provide IPv6 parallel coverage at the
netfilter layer; for ufw specifically, IPv6 parity is implicit unless
the operator manually disabled it.

## Consequences

- 3 new Checks + 1 helper + 1 baseline section + 1 TI entry; Server-side
  audit Check count rises from 21 (v0.10.0) to 24 (v0.11.0).
- Default Lightsail run will emit `skipped` for all 3 firewall Checks
  (ubuntu user has no NOPASSWD sudo). This is an intentional UX choice;
  remediation field directs operators to the fix.
- Future 7e.3 (firewalld) and 7e.4 (pf) can adopt the same per-backend
  + bin-detect + best-effort-permission template wholesale.
```

- [ ] **Step 3: Commit**

```bash
git add docs/adr/0011-phase-7e2-firewall-audit.md
git commit -m "$(cat <<'EOF'
docs(adr): ADR-0011 Phase 7e.2 firewall audit decisions

Records six decisions: backend coverage (iptables+nft+ufw, defer
firewalld/pf), per-backend granularity, Approach A cross-talk
avoidance, best-effort permission model (no sudo policy in scope),
audit point coverage A+B+C, and ufw IPv6 deferral to 7e.5.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 9: Doc Updates + Version Bump

**Files:**
- Modify: `pyproject.toml` — version `0.10.0` → `0.11.0`
- Modify: `ROADMAP.md` — add 7e.2 row
- Modify: `CHANGELOG.md` — `[Unreleased]` Added section
- Modify: `README.md` — Check count 21 → 24
- Modify: `CLAUDE.md` — Total checks line + add 7e.2 paragraph in container/hardening doc section

- [ ] **Step 1: Bump version in `pyproject.toml`**

Locate the line `version = "0.10.0"` and change to `version = "0.11.0"`.

- [ ] **Step 2: Update `ROADMAP.md`**

Find the line `- [x] Phase 7f — 容器安全 Check ...` near the bottom of the Phase 7 section. Append immediately after it:

```markdown
- [x] Phase 7e.2 — Linux 主机加固防火墙 Check（iptables / nftables / ufw 三类，TI-LINUX-CIS-FIREWALL）— 2026-05-09
```

(Yes, 7e.2 lands chronologically after 7f even though numerically it's earlier — sort by completion date.)

- [ ] **Step 3: Update `CHANGELOG.md`**

Add to the top of the `[Unreleased]` `### Added` section (creating it if missing):

```markdown
### Added

- **Phase 7e.2 — Linux 主机加固防火墙 Check** (3 类：`iptables_audit` /
  `nftables_audit` / `ufw_audit`)。每个 Check 在 `run()` 顶部用共享
  helper `_firewall_common.detect_<bin>(ctx)` 探测后端二进制，未装即
  `not_applicable`。默认策略 / 后端激活 / IPv6 平行检查（点 A+B+C）。
  Approach A 串扰回避：iptables/nftables Check 检出 ufw 注入链/表后
  让位 `ufw_audit`。权限不足走 `skipped` + NOPASSWD sudoers 提示。
  服务端审计 Check 数：21 → 24（10 OpenClaw + 11 Linux hardening + 3
  Docker）。
- ssh_policy 扩展：9 条 firewall 二进制 `allowed_paths`（exact）+ 3
  段 `commands`（iptables / nft / ufw 各 1 个 read-only allowed_argv
  tuple）。
- TI 扩展：`TI-LINUX-CIS-FIREWALL`（internal）。
```

- [ ] **Step 4: Update `README.md`**

Run: `grep -n "21 类\|21 个" README.md`
Find the existing line referencing `21 类` (or similar) for server-audit Checks and update to `24 类（10 OpenClaw + 11 Linux hardening + 3 Docker）`.

- [ ] **Step 5: Update `CLAUDE.md`**

Open `CLAUDE.md`. Locate the line beginning with `**Total checks (v0.10.0):**` (in the Server-audit module section). Replace with:

```markdown
**Total checks (v0.11.0):** `native_audit`, `config_audit`, `version_patch`, `active_test` (Stage D); `exposure_scan`, `filesystem`, `process_forensics`, `credential_audit`, `plugin_static`, `log_review` (Stage E); `sysctl_audit`, `sshd_config_audit`, `sudoers_audit`, `suid_audit`, `world_writable`, `auditd_rules`, `systemd_service_blacklist`, `systemd_unit_lint` (Phase 7e/7e.1); `iptables_audit`, `nftables_audit`, `ufw_audit` (Phase 7e.2); `docker_daemon_audit`, `docker_runtime_audit`, `docker_image_cve` (Phase 7f) — 24 类。
```

Then add a new short subsection after the existing "## Container audit (Phase 7f, v0.10.0)" block:

```markdown
## Firewall audit (Phase 7e.2, v0.11.0)

- **`detect_iptables(ctx)` / `detect_nft(ctx)` / `detect_ufw(ctx)`** in `src/agentsec/audit/checks/_firewall_common.py` probe 3 candidate paths each via `stat -c %n`. Returns abs path on first hit, `None` on full miss. Each Check calls it independently in `run()` head; `None` → `ctx.status.not_applicable(...)`.
- **`output_managed_by_ufw(stdout, backend=...)`** detects ufw-injected chains (iptables) or `inet ufw` table (nftables), so iptables/nftables Checks can defer to `ufw_audit` and avoid double-reporting on Ubuntu+ufw hosts.
- **Best-effort permission model** — no `sudo` policy type. Permission-denied stderr ("Permission denied" / "Operation not permitted" / "must be root" / "you need to be root", lowercase substring match) → `skipped` with NOPASSWD remediation. Operators who want coverage add a NOPASSWD sudoers entry for the allowed_argv tuples and re-run.
- **ufw IPv6 detection deferred to 7e.5** — `ufw status verbose` does not surface that field; iptables/nftables Checks cover IPv6 parallel at netfilter layer.
- **Adding a new firewall backend (e.g. firewalld in 7e.3)**: add `_FOO_BIN_CANDIDATES` + `detect_foo` in `_firewall_common.py`, write a new `FooAuditCheck` mirroring the existing 3, add ssh_policy `allowed_argv` and 3 binary paths.
```

- [ ] **Step 6: Final regression gate**

Run all in parallel:
```
.venv/bin/pytest tests/ -q
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/python scripts/check_owasp_coverage.py
.venv/bin/python scripts/check_threat_refs.py
.venv/bin/python scripts/build_cve_db.py --check
```
Expected: all green; pytest count ≈1120; pyright 0 errors.

- [ ] **Step 7: Commit**

```bash
git add pyproject.toml ROADMAP.md CHANGELOG.md README.md CLAUDE.md
git commit -m "$(cat <<'EOF'
docs(7e.2): bump 0.11.0 + ROADMAP/CHANGELOG/README/CLAUDE.md

Phase 7e.2 closure: server-side audit Check count 21 → 24, version
bumped to 0.11.0. CLAUDE.md gets a new "Firewall audit (Phase 7e.2)"
subsection covering the helper API, permission model, IPv6 deferral
note, and how to add a new backend.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
EOF
)"
```

---

## Task 10: Lightsail Real-Machine Smoke (Operator Manual Step)

**Files:** none (this is a manual operator step, results recorded in memory)

- [ ] **Step 1: Run server-audit against Lightsail prod**

```bash
.venv/bin/agentsec server-audit --host 34.208.65.73 --user ubuntu \
  --key ~/.ssh/id_rsa --consent-file AUTHORIZATION.txt \
  --output ./report-2026-05-09/
```

Expected: completes without error. The 3 firewall Checks should each
emit a `skipped` card with `skip_reason` containing "需 root" or
"sudoers" — this is the normal best-effort outcome on a default
Lightsail account.

- [ ] **Step 2: Inspect the report**

```bash
grep -A 3 "iptables_audit\|nftables_audit\|ufw_audit" \
  ./report-2026-05-09/server-audit-report.md
```

Confirm: 3 sections, each shows a `skipped` status + the NOPASSWD
remediation line.

- [ ] **Step 3 (optional): Add NOPASSWD sudoers and re-run**

If verifying live coverage: SSH into Lightsail and add to
`/etc/sudoers.d/agentsec-firewall`:

```
ubuntu ALL=(root) NOPASSWD: /usr/sbin/iptables -S, /usr/sbin/ip6tables -S, /usr/sbin/nft -j list ruleset, /usr/sbin/ufw status verbose
```

Then in `agentsec` source, prefix the executor argv with `["sudo", "-n",
...]` for the 4 firewall commands (this is **not** part of 7e.2 —
intentional manual hack to verify coverage). Re-run server-audit and
record the actual finding count. **Revert** the sudoers + source hack
before committing.

- [ ] **Step 4: Update `project_openclaw_lightsail_prod.md` memory**

Add a paragraph to the memory file noting:
- Date of run.
- Expected result (3 firewall Checks all skipped on default ubuntu user).
- If live coverage was verified (Step 3): actual finding count + which
  Check fired what.
- Any unexpected findings.

This step does not produce a commit; it's a memory update only.

---

## Self-Review

After completing all tasks, perform the self-review:

1. **Spec coverage** — Walk the spec section by section and confirm every requirement maps to a task:
   - §1 目标 → Tasks 2–5 (3 Checks + helper)
   - §2 范围 / §3.1 文件布局 → Tasks 1–8 collectively
   - §3.2 平台 gating → embedded in each Check (Tasks 3, 4, 5)
   - §3.3 `_REQUIRED_PATHS` → embedded in each Check
   - §3.4 backend 探测 + 串扰回避 → Task 2
   - §3.5 权限不足判定 → embedded in each Check
   - §3.6 baseline 文件结构 → Task 1
   - §4 各 Check 命令面 → Tasks 3–5
   - §5 数据流 → embedded in each Check
   - §6 错误处理 → embedded in each Check
   - §7 SSH policy 扩展 → Task 1
   - §8 Threat Intel 扩展 → Task 1
   - §9 测试策略 → Tasks 2–5 (unit) + Task 7 (integration + sample)
   - §10 CLI 注册 → Task 6
   - §11 文档变更 → Tasks 8–9
   - §12 验收清单 → Task 9 (final regression gate)
   - §13 后续展望 → out of scope (recorded in ADR-0011 D5/D6)

2. **Final verification** — confirm `git log --oneline` shows the expected ~9 commits and `git status` is clean.

3. **Push** when the user is ready.
