# Phase 7e.3 firewalld 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 `FirewalldAuditCheck` so `agentsec server-audit` covers the RHEL/CentOS Stream/Fedora default firewall (firewalld), and yield from `iptables_audit` / `nftables_audit` when firewalld manages the underlying ruleset to avoid double-reporting.

**Architecture:** One new Check (3 fixed `firewall-cmd` calls: `--state` + `--get-default-zone` + `--list-all-zones`) + 3 new helpers in `_firewall_common.py` (`detect_firewalld`, `output_managed_by_firewalld`, supporting candidates tuple) + 2 cross-talk yields in existing `iptables_audit.py` / `nftables_audit.py` + ssh_policy.yaml extension (2 paths + 1 commands section). Pure parsing / no D-Bus client / no extra IOC database.

**Tech Stack:** Python 3.11+, pytest, asyncio. No new third-party deps.

**Spec:** `docs/superpowers/specs/2026-05-12-phase-7e3-firewalld-design.md`

---

## File Structure

**Create:**
- `src/agentsec/audit/checks/firewalld_audit.py` — new Check + `parse_firewalld_zones` helper + `ZoneInfo` dataclass
- `tests/test_firewalld_audit.py` — 11 unit tests
- `tests/fixtures/firewall/` — 9 new fixtures (3 short text + 4 zone-list blobs + 2 cross-talk dumps)
- `docs/adr/0017-phase-7e3-firewalld-audit.md` — written at end of plan

**Modify:**
- `src/agentsec/audit/checks/_firewall_common.py` — append `_FIREWALLD_BIN_CANDIDATES`, `detect_firewalld`, `output_managed_by_firewalld`
- `src/agentsec/audit/checks/iptables_audit.py` — import `output_managed_by_firewalld`, add `_FIREWALLD_TAKEOVER` sentinel, add yield branch in `_evaluate_family` and in `run`
- `src/agentsec/audit/checks/nftables_audit.py` — same pattern (single stdout)
- `src/agentsec/audit/ssh_policy.yaml` — +2 `allowed_paths` exact, +1 `commands.firewall-cmd` section
- `src/agentsec/cli.py` — import + register `FirewalldAuditCheck()` in two sites (line ≈ 611 and 940)
- `tests/test_firewall_common.py` — +6 cases (detect + output_managed_by_firewalld matrix)
- `tests/test_iptables_audit.py` — +1 case (`test_firewalld_chains_detected_not_applicable`)
- `tests/test_nftables_audit.py` — +1 case (same pattern)
- `tests/audit/test_command_policy.py` — +4 cases (3 accept + 1 reject)
- `pyproject.toml` — version bump to `0.17.0`
- `CHANGELOG.md` / `ROADMAP.md` / `README.md` / `CLAUDE.md` — release notes

---

## Task 1: Add firewall fixtures

**Files:**
- Create: `tests/fixtures/firewall/firewalld_state_running.txt`
- Create: `tests/fixtures/firewall/firewalld_state_stopped.txt`
- Create: `tests/fixtures/firewall/firewalld_default_zone_public.txt`
- Create: `tests/fixtures/firewall/firewalld_list_all_default_only.txt`
- Create: `tests/fixtures/firewall/firewalld_list_all_default_accept.txt`
- Create: `tests/fixtures/firewall/firewalld_list_all_multi_active_mixed.txt`
- Create: `tests/fixtures/firewall/firewalld_list_all_empty.txt`
- Create: `tests/fixtures/firewall/iptables_with_firewalld.txt`
- Create: `tests/fixtures/firewall/nft_ruleset_with_firewalld.json`

- [ ] **Step 1: Create the running-state fixture**

Write `tests/fixtures/firewall/firewalld_state_running.txt`:

```
running
```

(single line, trailing newline)

- [ ] **Step 2: Create the stopped-state fixture**

Write `tests/fixtures/firewall/firewalld_state_stopped.txt`:

```
not running
```

- [ ] **Step 3: Create the default-zone fixture**

Write `tests/fixtures/firewall/firewalld_default_zone_public.txt`:

```
public
```

- [ ] **Step 4: Create the default-only-active list-all fixture**

Write `tests/fixtures/firewall/firewalld_list_all_default_only.txt`:

```
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: eth0
  sources: 
  services: ssh dhcpv6-client
  ports: 
  protocols: 
  forward: yes
  masquerade: no
  forward-ports: 
  source-ports: 
  icmp-blocks: 
  rich rules: 

block
  target: %%REJECT%%
  icmp-block-inversion: no
  interfaces: 
  sources: 
  services: 
  ports: 
  protocols: 
  forward: no
  masquerade: no
  forward-ports: 
  source-ports: 
  icmp-blocks: 
  rich rules: 

dmz
  target: default
  icmp-block-inversion: no
  interfaces: 
  sources: 
  services: ssh
  ports: 
  protocols: 
  forward: no
  masquerade: no
  forward-ports: 
  source-ports: 
  icmp-blocks: 
  rich rules: 

drop
  target: DROP
  icmp-block-inversion: no
  interfaces: 
  sources: 
  services: 
  ports: 
  protocols: 
  forward: no
  masquerade: no
  forward-ports: 
  source-ports: 
  icmp-blocks: 
  rich rules: 

trusted
  target: ACCEPT
  icmp-block-inversion: no
  interfaces: 
  sources: 
  services: 
  ports: 
  protocols: 
  forward: yes
  masquerade: no
  forward-ports: 
  source-ports: 
  icmp-blocks: 
  rich rules: 
```

(Note: `trusted` zone has `target: ACCEPT` but it is **not active** — no interfaces, no sources — so it should NOT trigger a finding. This fixture exists to verify the `is_active or is_default` filter rejects it.)

- [ ] **Step 5: Create the default-accept list-all fixture**

Write `tests/fixtures/firewall/firewalld_list_all_default_accept.txt` — copy the previous fixture and change ONLY the `public` zone's `target: default` line to `target: ACCEPT`. All other zones unchanged.

- [ ] **Step 6: Create the multi-active-mixed list-all fixture**

Write `tests/fixtures/firewall/firewalld_list_all_multi_active_mixed.txt` — copy `firewalld_list_all_default_only.txt` and:
- Change the `dmz` zone header line from `dmz` to `dmz (active)` AND change its `interfaces:` line to `interfaces: eth1` AND change `target: default` to `target: ACCEPT`.
- Leave `public` as-is (active, default, target=default).

Expected: 1 high finding for `dmz` only; 0 findings for `public`.

- [ ] **Step 7: Create the empty list-all fixture**

Write `tests/fixtures/firewall/firewalld_list_all_empty.txt` as an **empty** file (zero bytes).

- [ ] **Step 8: Create the iptables-with-firewalld cross-talk fixture**

Write `tests/fixtures/firewall/iptables_with_firewalld.txt`:

```
-P INPUT ACCEPT
-P FORWARD ACCEPT
-P OUTPUT ACCEPT
-N INPUT_ZONES
-N FORWARD_IN_ZONES
-N FORWARD_OUT_ZONES
-N IN_public
-N IN_public_allow
-N IN_public_deny
-N IN_public_log
-N FWDI_public
-N FWDI_public_allow
-N FWDI_public_deny
-N FWDI_public_log
-N FWDO_public
-N FWDO_public_allow
-N FWDO_public_deny
-N FWDO_public_log
-A INPUT -j INPUT_ZONES
-A INPUT_ZONES -i eth0 -g IN_public
-A IN_public -j IN_public_log
-A IN_public -j IN_public_deny
-A IN_public -j IN_public_allow
-A IN_public_allow -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT
```

- [ ] **Step 9: Create the nftables-with-firewalld cross-talk fixture**

Write `tests/fixtures/firewall/nft_ruleset_with_firewalld.json`:

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

- [ ] **Step 10: Verify all 9 fixtures land**

Run: `ls tests/fixtures/firewall/firewalld_*.txt tests/fixtures/firewall/firewalld_*.json tests/fixtures/firewall/iptables_with_firewalld.txt tests/fixtures/firewall/nft_ruleset_with_firewalld.json`

Expected output: all 9 paths listed (note: the json fixture name doesn't match the wildcard pattern — adjust your `ls` line as needed; the actual fixture is `nft_ruleset_with_firewalld.json`, lacking `firewalld_` prefix because it follows the existing 7e.2 naming convention `nft_ruleset_*.json`).

- [ ] **Step 11: Commit**

```bash
git add tests/fixtures/firewall/firewalld_state_running.txt \
        tests/fixtures/firewall/firewalld_state_stopped.txt \
        tests/fixtures/firewall/firewalld_default_zone_public.txt \
        tests/fixtures/firewall/firewalld_list_all_default_only.txt \
        tests/fixtures/firewall/firewalld_list_all_default_accept.txt \
        tests/fixtures/firewall/firewalld_list_all_multi_active_mixed.txt \
        tests/fixtures/firewall/firewalld_list_all_empty.txt \
        tests/fixtures/firewall/iptables_with_firewalld.txt \
        tests/fixtures/firewall/nft_ruleset_with_firewalld.json
git commit -m "test(7e.3): add firewalld + cross-talk fixtures

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

---

## Task 2: detect_firewalld helper + tests

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

- [ ] **Step 1: Write failing tests for `detect_firewalld`**

Append to `tests/test_firewall_common.py` (also update the import at the top to include `detect_firewalld`):

```python
# (top-of-file import update — change existing import block to add detect_firewalld:)
from agentsec.audit.checks._firewall_common import (
    detect_firewalld,
    detect_iptables,
    detect_nft,
    detect_ufw,
    output_managed_by_ufw,
)


# ---- detect_firewalld ----

def test_detect_firewalld_finds_usr_bin():
    assert detect_firewalld(_ctx(_StubExecutor("/usr/bin/firewall-cmd"))) == "/usr/bin/firewall-cmd"


def test_detect_firewalld_finds_bin():
    assert detect_firewalld(_ctx(_StubExecutor("/bin/firewall-cmd"))) == "/bin/firewall-cmd"


def test_detect_firewalld_returns_none_when_absent():
    assert detect_firewalld(_ctx(_StubExecutor(None))) is None


def test_detect_firewalld_returns_none_when_all_rejected():
    assert detect_firewalld(_ctx(_AllRejectedExecutor())) is None
```

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

Run: `.venv/bin/pytest tests/test_firewall_common.py::test_detect_firewalld_finds_usr_bin -v`

Expected: FAIL with `ImportError: cannot import name 'detect_firewalld' from 'agentsec.audit.checks._firewall_common'`.

- [ ] **Step 3: Add `_FIREWALLD_BIN_CANDIDATES` and `detect_firewalld`**

Edit `src/agentsec/audit/checks/_firewall_common.py`. Add the candidates tuple right after `_UFW_BIN_CANDIDATES` (line ≈ 25):

```python
_FIREWALLD_BIN_CANDIDATES: tuple[str, ...] = (
    "/usr/bin/firewall-cmd",
    "/bin/firewall-cmd",
)
```

Add the function right after `detect_ufw` (line ≈ 61):

```python
def detect_firewalld(ctx: CheckContext) -> str | None:
    """探测 firewall-cmd 二进制路径；首个 stat 命中即返，全失败返 None。"""
    return _stat_probe(ctx, _FIREWALLD_BIN_CANDIDATES)
```

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

Run: `.venv/bin/pytest tests/test_firewall_common.py -k "detect_firewalld" -v`

Expected: 4 PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/checks/_firewall_common.py tests/test_firewall_common.py
git commit -m "feat(7e.3): detect_firewalld helper

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

---

## Task 3: output_managed_by_firewalld helper + tests

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

- [ ] **Step 1: Write failing tests**

Append to `tests/test_firewall_common.py` (update top import to also include `output_managed_by_firewalld`):

```python
# (top-of-file: also add output_managed_by_firewalld to the import list)


# ---- output_managed_by_firewalld ----

def test_output_managed_by_firewalld_iptables_in_zone():
    s = "-P INPUT ACCEPT\n-N IN_public\n-N IN_public_allow\n"
    assert output_managed_by_firewalld(s, backend="iptables") is True


def test_output_managed_by_firewalld_iptables_fwdi():
    s = "-P FORWARD ACCEPT\n-N FWDI_public\n"
    assert output_managed_by_firewalld(s, backend="iptables") is True


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


def test_output_managed_by_firewalld_nftables_table():
    s = '{"nftables": [{"table": {"family": "inet", "name": "firewalld"}}]}'
    assert output_managed_by_firewalld(s, backend="nftables") is True


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


def test_output_managed_by_firewalld_unknown_backend_raises():
    import pytest
    with pytest.raises(ValueError):
        output_managed_by_firewalld("", backend="bogus")
```

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

Run: `.venv/bin/pytest tests/test_firewall_common.py -k "output_managed_by_firewalld" -v`

Expected: 6 FAIL with `ImportError: cannot import name 'output_managed_by_firewalld'`.

- [ ] **Step 3: Add `output_managed_by_firewalld`**

Append to `src/agentsec/audit/checks/_firewall_common.py` (after `output_managed_by_ufw`):

```python
def output_managed_by_firewalld(stdout: str, *, backend: str) -> bool:
    """检测目标后端 dump 是否被 firewalld 接管，避免 iptables/nft Check
    与 firewalld_audit 双报。

    backend="iptables"：扫两类 firewalld 独有自建链前缀（任一命中即真）：
      1. 含 '-N IN_' 行（per-zone input chain）
      2. 含 '-N FWDI_' 行（per-zone forward-in chain）
      iptables 默认配置不会出现这两个前缀。

    backend="nftables"：JSON 解析后只看顶层 table.name == "firewalld"
      （现代 firewalld 默认以 nftables 为后端，建一个 inet 家族的
      `firewalld` 表）。解析失败回退 False（让上层 JSONDecodeError
      走自己的 skipped 分支）。
    """
    if backend == "iptables":
        if "-N IN_" in stdout or "-N FWDI_" in stdout:
            return True
        return False
    if backend == "nftables":
        try:
            parsed = json.loads(stdout)
        except json.JSONDecodeError:
            return False
        for entry in parsed.get("nftables", []):
            table = entry.get("table")
            if isinstance(table, dict) and table.get("name") == "firewalld":
                return True
        return False
    raise ValueError(f"unknown backend {backend!r}")
```

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

Run: `.venv/bin/pytest tests/test_firewall_common.py -k "output_managed_by_firewalld" -v`

Expected: 6 PASS.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/checks/_firewall_common.py tests/test_firewall_common.py
git commit -m "feat(7e.3): output_managed_by_firewalld helper

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

---

## Task 4: parse_firewalld_zones helper + tests

**Files:**
- Create: `src/agentsec/audit/checks/firewalld_audit.py`
- Create: `tests/test_firewalld_audit.py`

- [ ] **Step 1: Write failing tests for the parser**

Create `tests/test_firewalld_audit.py`:

```python
"""FirewalldAuditCheck unit tests (Phase 7e.3)."""

from __future__ import annotations

from pathlib import Path

from agentsec.audit.checks.firewalld_audit import ZoneInfo, parse_firewalld_zones

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


def test_parse_default_only_active():
    text = (_FIXTURES / "firewalld_list_all_default_only.txt").read_text()
    zones = parse_firewalld_zones(text, default_zone="public")
    by_name = {z.name: z for z in zones}
    assert by_name["public"] == ZoneInfo(
        name="public", target="default", is_active=True, is_default=True
    )
    assert by_name["block"].is_active is False
    assert by_name["block"].target == "%%REJECT%%"
    assert by_name["dmz"].is_active is False
    assert by_name["drop"].target == "DROP"
    assert by_name["trusted"].target == "ACCEPT"
    assert by_name["trusted"].is_active is False  # no interfaces, no sources


def test_parse_marks_active_via_interfaces_when_no_marker():
    """firewalld < 0.6 may omit the (active) header marker but still bind
    interfaces. The parser must fall back to the interfaces line."""
    text = (
        "home\n"
        "  target: default\n"
        "  interfaces: wlan0\n"
        "  sources: \n"
    )
    zones = parse_firewalld_zones(text, default_zone="public")
    assert zones[0].name == "home"
    assert zones[0].is_active is True
    assert zones[0].is_default is False


def test_parse_marks_active_via_sources():
    text = (
        "trusted\n"
        "  target: ACCEPT\n"
        "  interfaces: \n"
        "  sources: 10.0.0.0/8\n"
    )
    zones = parse_firewalld_zones(text, default_zone="public")
    assert zones[0].is_active is True


def test_parse_empty_returns_empty_list():
    assert parse_firewalld_zones("", default_zone="public") == []


def test_parse_zone_without_target_emits_empty_target():
    text = (
        "weird\n"
        "  interfaces: eth9\n"
    )
    zones = parse_firewalld_zones(text, default_zone="public")
    assert zones[0].target == ""
```

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

Run: `.venv/bin/pytest tests/test_firewalld_audit.py -v`

Expected: FAIL with `ModuleNotFoundError: No module named 'agentsec.audit.checks.firewalld_audit'`.

- [ ] **Step 3: Create `firewalld_audit.py` with parser only**

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

```python
"""firewalld 主机防火墙审计（Phase 7e.3）。

Pulls 'firewall-cmd --state' + '--get-default-zone' + '--list-all-zones'
and emits one Finding per ACCEPT-target zone (default zone = critical,
other active zones = high). Service-stopped emits 1 critical and returns
without running the remaining commands. Cross-talk yield: when iptables
or nftables Checks see firewalld-injected chains/tables they go
not_applicable; this Check is the canonical evaluator for firewalld
hosts.
"""

from __future__ import annotations

import re
from dataclasses import dataclass


@dataclass(frozen=True)
class ZoneInfo:
    name: str
    target: str
    is_active: bool
    is_default: bool


_ZONE_HEADER = re.compile(r"^(\S+)(?:\s+\(active\))?\s*$")
_INDENTED_TARGET = re.compile(r"^\s+target:\s+(\S+)\s*$")
_INDENTED_INTERFACES = re.compile(r"^\s+interfaces:\s*(.*)$")
_INDENTED_SOURCES = re.compile(r"^\s+sources:\s*(.*)$")


def parse_firewalld_zones(stdout: str, *, default_zone: str) -> list[ZoneInfo]:
    """Slice 'firewall-cmd --list-all-zones' output into per-zone records.

    Each zone block starts with a header line that has NO leading
    whitespace and contains the zone name (optionally followed by
    '(active)'). Subsequent indented lines carry the zone's fields.
    'target:' is captured directly; 'is_active' is True if the header
    has the (active) marker OR if 'interfaces:' / 'sources:' is non-empty
    (fallback for ancient firewalld versions that omit the marker).
    """
    zones: list[ZoneInfo] = []
    current_name: str | None = None
    current_target: str = ""
    current_active: bool = False

    def _flush() -> None:
        if current_name is None:
            return
        zones.append(ZoneInfo(
            name=current_name,
            target=current_target,
            is_active=current_active,
            is_default=(current_name == default_zone),
        ))

    for raw in stdout.splitlines():
        if not raw.strip():
            continue
        if not raw[0].isspace():
            # zone header
            _flush()
            m = _ZONE_HEADER.match(raw)
            if m is None:
                current_name = None
                continue
            current_name = m.group(1)
            current_target = ""
            current_active = "(active)" in raw
            continue
        # indented line
        m = _INDENTED_TARGET.match(raw)
        if m:
            current_target = m.group(1)
            continue
        m = _INDENTED_INTERFACES.match(raw)
        if m and m.group(1).strip():
            current_active = True
            continue
        m = _INDENTED_SOURCES.match(raw)
        if m and m.group(1).strip():
            current_active = True
            continue

    _flush()
    return zones
```

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

Run: `.venv/bin/pytest tests/test_firewalld_audit.py -v`

Expected: 5 PASS (test_parse_*).

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/checks/firewalld_audit.py tests/test_firewalld_audit.py
git commit -m "feat(7e.3): parse_firewalld_zones + ZoneInfo dataclass

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

---

## Task 5: FirewalldAuditCheck — gating, service-state branches, and remaining tests

**Files:**
- Modify: `src/agentsec/audit/checks/firewalld_audit.py`
- Modify: `tests/test_firewalld_audit.py`

- [ ] **Step 1: Write failing tests for the Check entry-points**

Append to `tests/test_firewalld_audit.py`:

```python
import asyncio

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.firewalld_audit import FirewalldAuditCheck
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.redactor import Redactor
from agentsec.audit.types import RunResult

_BIN = "/usr/bin/firewall-cmd"
_STATE = ("firewall-cmd", "--state")
_GET_DEFAULT = ("firewall-cmd", "--get-default-zone")
_LIST_ALL = ("firewall-cmd", "--list-all-zones")


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=Redactor(),
        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: str | None) -> dict[tuple, RunResult]:
    candidates = ("/usr/bin/firewall-cmd", "/bin/firewall-cmd")
    out: dict[tuple, RunResult] = {}
    for c in candidates:
        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_not_applicable_macos():
    ctx = _ctx(_RecordingExecutor({}), profile_name="macos")
    findings = _run(FirewalldAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_not_applicable is True


def test_not_applicable_no_binary():
    ctx = _ctx(_RecordingExecutor(_detect_responses(None)))
    findings = _run(FirewalldAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_not_applicable is True


def test_critical_when_service_stopped():
    text = (_FIXTURES / "firewalld_state_stopped.txt").read_text()
    responses = _detect_responses(_BIN)
    responses[_STATE] = _runresult(stdout=text, exit_code=252)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(FirewalldAuditCheck(), ctx)
    assert len(findings) == 1
    assert findings[0].severity == "critical"
    assert "未启用" in findings[0].title
    # Should NOT have called --get-default-zone or --list-all-zones
    called = {tuple(c) for c in ctx.executor.calls}
    assert _GET_DEFAULT not in called
    assert _LIST_ALL not in called


def test_skipped_when_state_perm_denied():
    responses = _detect_responses(_BIN)
    responses[_STATE] = _runresult(
        stderr="Authorization failed: Permission denied", exit_code=126,
    )
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(FirewalldAuditCheck(), 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_skipped_when_get_default_zone_empty():
    responses = _detect_responses(_BIN)
    responses[_STATE] = _runresult(stdout="running\n", exit_code=0)
    responses[_GET_DEFAULT] = _runresult(stdout="", exit_code=0)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(FirewalldAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is True


def test_skipped_when_list_all_rejected():
    responses = _detect_responses(_BIN)
    responses[_STATE] = _runresult(stdout="running\n", exit_code=0)
    responses[_GET_DEFAULT] = _runresult(stdout="public\n", exit_code=0)
    responses[_LIST_ALL] = _runresult(rejected=True)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(FirewalldAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is True
    assert "策略拒绝" in (ctx.status.skip_reason or "")
```

Note: `_RecordingExecutor` exposes `.calls` directly; the test uses `ctx.executor.calls` — make sure the executor instance is referenced via `ctx.executor` (not via a separately-bound variable).

- [ ] **Step 2: Run new tests to verify they fail**

Run: `.venv/bin/pytest tests/test_firewalld_audit.py -v`

Expected: 5 new test_* FAIL with `ImportError: cannot import name 'FirewalldAuditCheck'`. Existing 5 parse_ tests still PASS.

- [ ] **Step 3: Implement the Check entry + service-state branch**

Edit `src/agentsec/audit/checks/firewalld_audit.py`. Add at top of file:

```python
import hashlib

from ..findings import Finding
from ._firewall_common import detect_firewalld, looks_like_perm_denied
from .base import Check, CheckContext
```

Append after `parse_firewalld_zones`:

```python
class FirewalldAuditCheck(Check):
    id = "firewalld_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_firewalld(ctx) is None:
            ctx.status.not_applicable(
                f"{self.id}：被审主机未检出 firewall-cmd 二进制"
            )
            return []

        # Cmd #1: --state
        state = ctx.executor.run(["firewall-cmd", "--state"])
        if state.rejected:
            ctx.status.skipped(
                f"{self.id}：firewall-cmd 命令被策略拒绝 ({state.reject_reason})"
            )
            return []
        if state.exit_code != 0:
            if looks_like_perm_denied(state.stderr):
                ctx.status.skipped(
                    f"{self.id}：权限不足，需 root 或 NOPASSWD sudoers "
                    "（在该主机为执行用户配置 NOPASSWD sudoers，"
                    "或以 root 身份运行 agentsec server-audit）"
                )
                return []
            return [self.make_finding(
                ctx,
                severity="critical",
                title="firewalld 已安装但未启用",
                description=(
                    "默认接收策略 / 后端未激活 / IPv6 平行漏洞 — "
                    "CIS Linux 加固基线"
                ),
                evidence={"state_exit_code": state.exit_code,
                          "state_stdout": state.stdout.strip()},
                remediation="`sudo systemctl enable --now firewalld`",
                threat_refs=["TI-LINUX-CIS-FIREWALL"],
            )]

        # Cmd #2: --get-default-zone
        dz = ctx.executor.run(["firewall-cmd", "--get-default-zone"])
        if dz.rejected:
            ctx.status.skipped(
                f"{self.id}：firewall-cmd --get-default-zone 被策略拒绝 ({dz.reject_reason})"
            )
            return []
        if dz.exit_code != 0:
            if looks_like_perm_denied(dz.stderr):
                ctx.status.skipped(
                    f"{self.id}：权限不足，需 root 或 NOPASSWD sudoers"
                )
                return []
            ctx.status.skipped(
                f"{self.id}：firewall-cmd --get-default-zone 退出码 {dz.exit_code}"
            )
            return []
        if not dz.stdout.strip():
            ctx.status.skipped(
                f"{self.id}：firewall-cmd --get-default-zone 输出为空"
            )
            return []
        default_zone = dz.stdout.strip().split()[0]

        # Cmd #3: --list-all-zones
        la = ctx.executor.run(["firewall-cmd", "--list-all-zones"])
        if la.rejected:
            ctx.status.skipped(
                f"{self.id}：firewall-cmd --list-all-zones 被策略拒绝 ({la.reject_reason})"
            )
            return []
        if la.exit_code != 0:
            if looks_like_perm_denied(la.stderr):
                ctx.status.skipped(
                    f"{self.id}：权限不足，需 root 或 NOPASSWD sudoers"
                )
                return []
            ctx.status.skipped(
                f"{self.id}：firewall-cmd --list-all-zones 退出码 {la.exit_code}"
            )
            return []

        zones = parse_firewalld_zones(la.stdout, default_zone=default_zone)
        candidates = [z for z in zones if z.is_active or z.is_default]

        findings: list[Finding] = []
        blob_sha = hashlib.sha256(la.stdout.encode("utf-8")).hexdigest()
        for z in candidates:
            if z.target != "ACCEPT":
                continue
            severity = "critical" if z.is_default else "high"
            title = (
                f"firewalld 默认 zone {z.name} target=ACCEPT（应为 default/DROP/REJECT）"
                if z.is_default else
                f"firewalld active zone {z.name} target=ACCEPT"
            )
            findings.append(self.make_finding(
                ctx,
                severity=severity,
                title=title,
                description=(
                    "默认接收策略 / 后端未激活 / IPv6 平行漏洞 — "
                    "CIS Linux 加固基线"
                ),
                evidence={
                    "zone": z.name,
                    "target": z.target,
                    "is_default": z.is_default,
                    "is_active": z.is_active,
                    "blob_sha256": blob_sha,
                },
                remediation=(
                    f"`sudo firewall-cmd --permanent --zone={z.name} "
                    f"--set-target=default && sudo firewall-cmd --reload`"
                ),
                threat_refs=["TI-LINUX-CIS-FIREWALL"],
            ))
        return findings
```

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

Run: `.venv/bin/pytest tests/test_firewalld_audit.py -v`

Expected: 10 PASS (5 parse_ + 5 entry/state).

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/checks/firewalld_audit.py tests/test_firewalld_audit.py
git commit -m "feat(7e.3): FirewalldAuditCheck core (gating + 3-command pipeline)

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

---

## Task 6: FirewalldAuditCheck — happy-path / clean / critical / high tests

**Files:**
- Modify: `tests/test_firewalld_audit.py`

- [ ] **Step 1: Write the four happy-path tests**

Append to `tests/test_firewalld_audit.py`:

```python
def _three_cmd_responses(*, default_zone: str, list_all_text: str):
    responses = _detect_responses(_BIN)
    responses[_STATE] = _runresult(stdout="running\n", exit_code=0)
    responses[_GET_DEFAULT] = _runresult(stdout=default_zone + "\n", exit_code=0)
    responses[_LIST_ALL] = _runresult(stdout=list_all_text, exit_code=0)
    return responses


def test_clean_default_only_active():
    text = (_FIXTURES / "firewalld_list_all_default_only.txt").read_text()
    responses = _three_cmd_responses(default_zone="public", list_all_text=text)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(FirewalldAuditCheck(), ctx)
    # public is active+default+target=default → no finding
    # trusted is target=ACCEPT but NOT active and NOT default → no finding
    assert findings == []


def test_critical_default_zone_accept():
    text = (_FIXTURES / "firewalld_list_all_default_accept.txt").read_text()
    responses = _three_cmd_responses(default_zone="public", list_all_text=text)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(FirewalldAuditCheck(), ctx)
    assert len(findings) == 1
    f = findings[0]
    assert f.severity == "critical"
    assert f.evidence["is_default"] is True
    assert f.evidence["zone"] == "public"
    assert f.evidence["target"] == "ACCEPT"


def test_high_non_default_active_accept():
    text = (_FIXTURES / "firewalld_list_all_multi_active_mixed.txt").read_text()
    responses = _three_cmd_responses(default_zone="public", list_all_text=text)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(FirewalldAuditCheck(), ctx)
    assert len(findings) == 1
    f = findings[0]
    assert f.severity == "high"
    assert f.evidence["is_default"] is False
    assert f.evidence["zone"] == "dmz"


def test_empty_list_all_skipped():
    """Zero-byte --list-all-zones output → `not la.stdout.strip()` triggers
    the skipped branch (matches spec §6 row 'Cmd #3 stdout 空 → skipped').
    Distinct from the "valid blob, parses to zero zones" set_ok branch,
    which is covered by the parser unit test test_parse_empty_returns_empty_list."""
    text = (_FIXTURES / "firewalld_list_all_empty.txt").read_text()
    assert text == ""  # fixture sanity
    responses = _three_cmd_responses(default_zone="public", list_all_text=text)
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(FirewalldAuditCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped is True
```

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

Run: `.venv/bin/pytest tests/test_firewalld_audit.py -v`

Expected: 14 PASS (10 prior + 4 new).

- [ ] **Step 3: Commit**

```bash
git add tests/test_firewalld_audit.py
git commit -m "test(7e.3): FirewalldAuditCheck happy-path matrix

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

---

## Task 7: ssh_policy.yaml extension + policy validation tests

**Files:**
- Modify: `src/agentsec/audit/ssh_policy.yaml`
- Modify: `tests/audit/test_command_policy.py`

- [ ] **Step 1: Write failing policy-validation tests**

Append to `tests/audit/test_command_policy.py`:

```python
def test_firewall_cmd_state_argv_allowed(policy):
    p = policy
    result = p.validate(["firewall-cmd", "--state"])
    assert result.allowed, f"unexpected reject: {result.reject_reason}"


def test_firewall_cmd_get_default_zone_argv_allowed(policy):
    p = policy
    result = p.validate(["firewall-cmd", "--get-default-zone"])
    assert result.allowed, f"unexpected reject: {result.reject_reason}"


def test_firewall_cmd_list_all_zones_argv_allowed(policy):
    p = policy
    result = p.validate(["firewall-cmd", "--list-all-zones"])
    assert result.allowed, f"unexpected reject: {result.reject_reason}"


def test_firewall_cmd_add_service_rejected(policy):
    p = policy
    result = p.validate(["firewall-cmd", "--add-service=ssh"])
    assert not result.allowed
```

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

Run: `.venv/bin/pytest tests/audit/test_command_policy.py -k "firewall_cmd" -v`

Expected: 3 of 4 FAIL with reject (firewall-cmd not in allowed commands). The 4th (`add_service`) may already PASS by accident because the whole command is unknown — confirm reason:

```
result.reject_reason should mention "unknown command" or similar
```

After implementation it should reject for the *correct* reason (argv mismatch).

- [ ] **Step 3: Add `allowed_paths` entries**

Edit `src/agentsec/audit/ssh_policy.yaml`. Find the existing 9 firewall bin lines (≈ lines 28–37, ending at `/usr/bin/ufw`). Insert immediately after them, before the cron-prefix lines (which start at `- {kind: prefix, path: "/etc/cron.d/"}`):

```yaml
  # firewalld bin-resolver candidates (Phase 7e.3) — stat-probe only, exact paths
  - {kind: exact,  path: "/usr/bin/firewall-cmd"}
  - {kind: exact,  path: "/bin/firewall-cmd"}
```

- [ ] **Step 4: Add `commands.firewall-cmd` section**

Edit `src/agentsec/audit/ssh_policy.yaml`. Find the existing `ufw:` commands block (≈ line 232–236, ending with `["ufw", "status", "verbose"]`). Insert immediately after, before the `# ---- write operations ----` header:

```yaml
  firewall-cmd:
    # Phase 7e.3 — read-only zone introspection. Write operations
    # (--add-service, --add-port, --remove-service, --remove-port,
    #  --add-rich-rule, --remove-rich-rule, --reload, --runtime-to-permanent,
    #  --panic-on, --panic-off, --set-default-zone, --new-zone, --delete-zone,
    #  --change-interface) are NOT in any allowed_argv tuple and unreachable
    # by construction.
    allowed_argv:
      - ["firewall-cmd", "--state"]
      - ["firewall-cmd", "--get-default-zone"]
      - ["firewall-cmd", "--list-all-zones"]
```

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

Run: `.venv/bin/pytest tests/audit/test_command_policy.py -k "firewall_cmd" -v`

Expected: 4 PASS.

- [ ] **Step 6: Run the full policy yaml-loadable test**

Run: `.venv/bin/pytest tests/audit/test_policy_yaml_loadable.py -v`

Expected: all PASS (sanity — no structural break).

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/ssh_policy.yaml tests/audit/test_command_policy.py
git commit -m "feat(7e.3): ssh_policy allows firewall-cmd read-only argv

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

---

## Task 8: Register FirewalldAuditCheck in cli.py

**Files:**
- Modify: `src/agentsec/cli.py`

- [ ] **Step 1: Add the import**

Edit `src/agentsec/cli.py`. The existing imports are alphabetical. Insert the `firewalld_audit` import between `filesystem` (line 27) and `go_supply_chain_audit` (line 28):

Locate this block (≈ lines 25-30):

```python
from .audit.checks.docker_runtime_audit import DockerRuntimeAuditCheck
from .audit.checks.exposure_scan import ExposureScanCheck
from .audit.checks.filesystem import FilesystemCheck
from .audit.checks.go_supply_chain_audit import GoSupplyChainAuditCheck
from .audit.checks.iptables_audit import IptablesAuditCheck
```

Replace with:

```python
from .audit.checks.docker_runtime_audit import DockerRuntimeAuditCheck
from .audit.checks.exposure_scan import ExposureScanCheck
from .audit.checks.filesystem import FilesystemCheck
from .audit.checks.firewalld_audit import FirewalldAuditCheck
from .audit.checks.go_supply_chain_audit import GoSupplyChainAuditCheck
from .audit.checks.iptables_audit import IptablesAuditCheck
```

- [ ] **Step 2: Register in the `evaluate` site (line ≈ 609–611)**

Locate (≈ lines 609–612):

```python
        IptablesAuditCheck(),
        NftablesAuditCheck(),
        UfwAuditCheck(),
        DockerDaemonAuditCheck(),
```

Replace with:

```python
        IptablesAuditCheck(),
        NftablesAuditCheck(),
        UfwAuditCheck(),
        FirewalldAuditCheck(),
        DockerDaemonAuditCheck(),
```

- [ ] **Step 3: Register in the standalone `server-audit` site (line ≈ 938–941)**

Locate (≈ lines 938–941):

```python
        IptablesAuditCheck(),
        NftablesAuditCheck(),
        UfwAuditCheck(),
        DockerDaemonAuditCheck(),
```

Replace with the same 4-line insertion as Step 2.

- [ ] **Step 4: Run a smoke test that the CLI still imports**

Run: `.venv/bin/python -c "from agentsec.cli import server_audit; print('ok')"`

Expected: `ok`. (Catches any import-order or typo error.)

- [ ] **Step 5: Run full pytest to confirm no regressions**

Run: `.venv/bin/pytest tests/ -q 2>&1 | tail -20`

Expected: all green; no new failures attributable to the registration.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/cli.py
git commit -m "feat(7e.3): register FirewalldAuditCheck in CLI orchestrators

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

---

## Task 9: iptables_audit — firewalld cross-talk yield

**Files:**
- Modify: `src/agentsec/audit/checks/iptables_audit.py`
- Modify: `tests/test_iptables_audit.py`

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

Append to `tests/test_iptables_audit.py`:

```python
def test_firewalld_chains_detected_not_applicable():
    text = (_FIXTURES / "iptables_with_firewalld.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 "firewalld" in (ctx.status.not_applicable_reason or "").lower()
```

- [ ] **Step 2: Run the test to verify it fails**

Run: `.venv/bin/pytest tests/test_iptables_audit.py::test_firewalld_chains_detected_not_applicable -v`

Expected: FAIL — likely with `len(findings) > 0` (current code reports findings on the ACCEPT default policies in the fixture) or `is_not_applicable is False`.

- [ ] **Step 3: Add the cross-talk yield**

Edit `src/agentsec/audit/checks/iptables_audit.py`.

Update the import block to include `output_managed_by_firewalld`:

```python
from ._firewall_common import (
    detect_iptables,
    looks_like_perm_denied,
    output_managed_by_firewalld,
    output_managed_by_ufw,
)
```

Add a new sentinel right after `_UFW_TAKEOVER` (≈ line 24):

```python
_FIREWALLD_TAKEOVER = object()
```

In `_evaluate_family`, locate (≈ line 125):

```python
        if output_managed_by_ufw(stdout, backend="iptables"):
            return _UFW_TAKEOVER
```

Insert immediately after:

```python
        if output_managed_by_firewalld(stdout, backend="iptables"):
            return _FIREWALLD_TAKEOVER
```

In `run`, locate (≈ lines 64-68) — the existing UFW takeover check — and add a parallel branch right after:

```python
        if v4_findings is _UFW_TAKEOVER or v6_findings is _UFW_TAKEOVER:
            ctx.status.not_applicable(
                f"{self.id}：检出 ufw 注入链，由 ufw_audit 评估"
            )
            return []
        if v4_findings is _FIREWALLD_TAKEOVER or v6_findings is _FIREWALLD_TAKEOVER:
            ctx.status.not_applicable(
                f"{self.id}：检出 firewalld 注入链，由 firewalld_audit 评估"
            )
            return []
```

The "neither family produced parseable output → skipped" check below this (the `if not v4_ok and not v6_ok:` block) needs no change — `_FIREWALLD_TAKEOVER` is not a list, and we already returned above.

- [ ] **Step 4: Run the test to verify it passes**

Run: `.venv/bin/pytest tests/test_iptables_audit.py -v`

Expected: all iptables_audit tests PASS (existing 6 + new 1 = 7).

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/checks/iptables_audit.py tests/test_iptables_audit.py
git commit -m "feat(7e.3): iptables_audit yields when firewalld manages chains

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

---

## Task 10: nftables_audit — firewalld cross-talk yield

**Files:**
- Modify: `src/agentsec/audit/checks/nftables_audit.py`
- Modify: `tests/test_nftables_audit.py`

- [ ] **Step 1: Read current nftables_audit to confirm pattern**

Run: `.venv/bin/python -c "import agentsec.audit.checks.nftables_audit as m; import inspect; print(inspect.getsource(m))" | head -80`

Confirm: file structure mirrors iptables_audit but with single `nft -j list ruleset` call (no v4/v6 split). It should already use `output_managed_by_ufw(stdout, backend="nftables")` somewhere.

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

Append to `tests/test_nftables_audit.py`:

```python
def test_firewalld_table_detected_not_applicable():
    text = (_FIXTURES / "nft_ruleset_with_firewalld.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
    assert "firewalld" in (ctx.status.not_applicable_reason or "").lower()
```

(Use `_NFT_LIST` if already defined in the test file, else `("nft", "-j", "list", "ruleset")`. Inspect the existing fixture-loading test in the same file for the exact constant name.)

- [ ] **Step 3: Run the test to verify it fails**

Run: `.venv/bin/pytest tests/test_nftables_audit.py::test_firewalld_table_detected_not_applicable -v`

Expected: FAIL.

- [ ] **Step 4: Add the cross-talk yield to nftables_audit.py**

Edit `src/agentsec/audit/checks/nftables_audit.py`.

- Update import block to add `output_managed_by_firewalld` next to `output_managed_by_ufw`.
- Wherever the existing module checks `output_managed_by_ufw(stdout, backend="nftables")` and returns a "ufw takeover" path, add a parallel `output_managed_by_firewalld(stdout, backend="nftables")` check immediately after, with `not_applicable` reason `"{self.id}：检出 firewalld 接管的 nftables 表，由 firewalld_audit 评估"`.

The exact insertion shape depends on whether nftables_audit uses sentinels (like iptables_audit) or inline `ctx.status.not_applicable + return []` (like ufw_audit). Mirror the existing UFW-takeover code style; do NOT introduce sentinels if the file has none today.

- [ ] **Step 5: Run the test to verify it passes**

Run: `.venv/bin/pytest tests/test_nftables_audit.py -v`

Expected: all nftables_audit tests PASS (existing N + new 1).

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/checks/nftables_audit.py tests/test_nftables_audit.py
git commit -m "feat(7e.3): nftables_audit yields when firewalld manages ruleset

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

---

## Task 11: Full-suite pytest + ruff

**Files:**
- (none modified — verification only)

- [ ] **Step 1: Run the full test suite**

Run: `.venv/bin/pytest tests/ -q 2>&1 | tail -5`

Expected: total count is **1362 + ~31 ≈ 1393**, all PASS. (Plan adds more granular tests than the spec's minimum 23: parser-level + extra detect/edge cases. Spec is a floor, not a ceiling.) If a count mismatch shows up well below ~1385, investigate which spec-promised tests didn't land before continuing.

- [ ] **Step 2: Run ruff**

Run: `.venv/bin/ruff check src/`

Expected: no errors. If lint errors appear (unused imports / line length), fix and re-run.

- [ ] **Step 3: Smoke test the CLI**

Run: `.venv/bin/agentsec --help`

Expected: help text prints; no import errors.

- [ ] **Step 4: Smoke test that registration list is correct**

Run:
```bash
.venv/bin/python -c "
from agentsec.cli import server_audit
import click
ctx = click.Context(server_audit)
print('OK: server_audit command imports without registration errors')
"
```

Expected: `OK: ...`

- [ ] **Step 5: No commit (verification step)**

If anything failed in steps 1-4, do not proceed. Fix the underlying issue and re-run.

---

## Task 12: ADR + version bump + release notes

**Files:**
- Create: `docs/adr/0017-phase-7e3-firewalld-audit.md`
- Modify: `pyproject.toml`
- Modify: `CHANGELOG.md`
- Modify: `ROADMAP.md`
- Modify: `README.md`
- Modify: `CLAUDE.md`

- [ ] **Step 1: Write the ADR**

Create `docs/adr/0017-phase-7e3-firewalld-audit.md`:

```markdown
# ADR-0017: Phase 7e.3 firewalld Audit

**Status:** Accepted
**Date:** 2026-05-12
**Predecessors:** ADR-0011 (Phase 7e.2 firewall audit)

## Context

`agentsec server-audit` Phase 7e.2 shipped iptables / nftables / ufw
Checks, covering Ubuntu / Debian / Alpine and raw RHEL. But the
out-of-the-box firewall on RHEL / CentOS Stream / Fedora is
**firewalld** (D-Bus + zone model), and 7e.2 spec §2 explicitly
deferred firewalld to this phase.

## Decision

Add `FirewalldAuditCheck` with a fixed 3-command pipeline:
1. `firewall-cmd --state` (service active probe)
2. `firewall-cmd --get-default-zone` (single-token output)
3. `firewall-cmd --list-all-zones` (one big blob)

Audit scope mirrors 7e.2 ufw scope: service-active + default-zone
target + per-active-zone target. Only `target == "ACCEPT"` reports;
default zone = critical, other active zones = high.

To avoid double-reporting on hosts where firewalld manages the
underlying kernel ruleset, `iptables_audit` and `nftables_audit` yield
`not_applicable` when they detect firewalld-injected chains
(`-N IN_*` / `-N FWDI_*` for iptables; `inet firewalld` table for
nftables) — parallel to the existing ufw-takeover yield from 7e.2.

## Consequences

- iptables/nftables Checks become silent on firewalld-managed RHEL hosts;
  firewalld_audit is the canonical evaluator there.
- Default polkit rules already allow non-root reads of all 3 commands,
  so permission failures should be rare; when they do occur, the same
  NOPASSWD-sudoers remediation as 7e.2 applies.
- ssh_policy gains 2 binary paths + 1 commands section (3 precise argv
  tuples). All read-only; write operations remain unreachable by
  construction.
- Future 7e.5 candidates: rich rules / dangerous services / runtime-vs-
  permanent drift / direct interface / `--get-log-denied` baseline.

## Alternatives Considered

- **Per-zone argv loop** (call `firewall-cmd --zone=<z> --get-target`
  per zone): rejected because it introduces an `args_schema` zone-name
  validator (asymmetric with 7e.2's all-`allowed_argv` form) and yields
  variable SSH round-trips.
- **Skip --get-default-zone, parse default from --list-all-zones**:
  rejected because list-all-zones output does not mark the default
  zone in any standard distribution.
- **No cross-talk yield (accept double-reporting)**: rejected because
  it produces noisy reports on every default RHEL host.
```

- [ ] **Step 2: Bump version**

Edit `pyproject.toml`. Find the `version = "0.16.0"` line and replace with `version = "0.17.0"`.

- [ ] **Step 3: Add CHANGELOG entry**

Edit `CHANGELOG.md`. Add a new section directly under the top heading:

```markdown
## v0.17.0 — 2026-05-12

### Phase 7e.3 — firewalld audit

- New `firewalld_audit` Check covers the RHEL/CentOS Stream/Fedora
  default firewall (firewalld) with a 3-command read-only pipeline
  (`firewall-cmd --state` + `--get-default-zone` + `--list-all-zones`).
- Audit scope mirrors 7e.2 ufw: service-active + default zone target +
  per-active-zone target. Reports only `target == "ACCEPT"` (default
  zone = critical, other active zones = high).
- `_firewall_common.py` extended with `detect_firewalld` +
  `output_managed_by_firewalld(stdout, *, backend)`.
- `iptables_audit` and `nftables_audit` now yield `not_applicable`
  when firewalld-injected chains/tables are detected, mirroring the
  existing ufw-takeover yield from 7e.2.
- `ssh_policy.yaml`: +2 `allowed_paths` exact (firewall-cmd binaries),
  +1 `commands` section (3 precise argv tuples; all write ops
  unreachable by construction).
- ADR-0017.
- Total Checks: 28 → 29.
```

- [ ] **Step 4: Update ROADMAP**

Edit `ROADMAP.md`. Find the section listing 7e.3 as a candidate /
upcoming phase. Move it under "Shipped" (or whatever the equivalent
heading is in the file) with a one-line summary, and update any
"Next candidates" list to keep 7e.5 (rich rules / drift / dangerous
services) and remove 7e.3.

- [ ] **Step 5: Update README Check count**

Edit `README.md`. Find "28 类" / "28 Check" / equivalent and bump to
"29 类" / "29 Check". Look in the Server Audit / Coverage section
(typical phrasing matches `git grep -n "28" README.md`).

- [ ] **Step 6: Update CLAUDE.md**

Edit `/Users/roger/Work/AgentSec/CLAUDE.md`:

a. In the "Total checks" line under "Stage E additions (v0.2.0)", change:
```
**Total checks (v0.15.0):** ... 28 类。
```
to:
```
**Total checks (v0.17.0):** ... `firewalld_audit` (Phase 7e.3) — 29 类。
```
(Insert `firewalld_audit` in the firewall group between `ufw_audit` and the next group's first item.)

b. Add a new section after the "## Container audit (Phase 7f, v0.10.0)"
or wherever fits the chronological narrative — directly after the
existing "## Firewall audit (Phase 7e.2, v0.11.0)" section:

```markdown
## firewalld audit (Phase 7e.3, v0.17.0)

- **`detect_firewalld(ctx)`** in `src/agentsec/audit/checks/_firewall_common.py` probes 2 candidate paths (`/usr/bin/firewall-cmd`, `/bin/firewall-cmd`) via `stat -c %n`. firewalld is RPM-distro-centric so 2 candidates suffice (vs. 3 for iptables/nft/ufw).
- **`FirewalldAuditCheck`** in `src/agentsec/audit/checks/firewalld_audit.py` runs a fixed 3-command pipeline (`--state` + `--get-default-zone` + `--list-all-zones`). Service-stopped emits 1 critical and short-circuits; otherwise each zone with `target == "ACCEPT"` emits a Finding (default zone = critical, other active zones = high).
- **Cross-talk yield** — `output_managed_by_firewalld(stdout, backend=...)` mirrors `output_managed_by_ufw`. iptables hits when stdout contains `-N IN_` or `-N FWDI_` (firewalld-injected per-zone chains); nftables hits on top-level table `name == "firewalld"`. iptables_audit / nftables_audit defer to firewalld_audit on RHEL/Fedora hosts.
- **Permissions** — default polkit rules allow non-root reads of `--state` / `--get-default-zone` / `--list-all-zones`. When operators tighten polkit, the standard NOPASSWD-sudoers remediation applies (same wording as 7e.2).
- **No baseline file** — judgments are hard-coded ("target ∈ {default, DROP, REJECT, %%REJECT%%} is safe"); no parameterization needed (mirrors `ufw_audit`).
- **Adding a 4th firewall backend (e.g., firewalld direct interface or pf)**: the pattern is now mature — add `_<NAME>_BIN_CANDIDATES` + `detect_<name>` in `_firewall_common.py`, write a new Check mirroring this one, add ssh_policy `allowed_argv` + binary paths.
```

- [ ] **Step 7: Run all tests one more time**

Run: `.venv/bin/pytest tests/ -q 2>&1 | tail -5`

Expected: all PASS. Total ≈ 1393.

- [ ] **Step 8: Run ruff**

Run: `.venv/bin/ruff check src/`

Expected: no errors.

- [ ] **Step 9: Commit**

```bash
git add docs/adr/0017-phase-7e3-firewalld-audit.md \
        pyproject.toml CHANGELOG.md ROADMAP.md README.md CLAUDE.md
git commit -m "docs(7e.3): ADR-0017 + v0.17.0 release notes

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

- [ ] **Step 10: Show the user the final state**

Run: `git log --oneline -15`

Expected: 11 new commits (T1 fixtures + T2 detect + T3 output_managed_by + T4 parser + T5 Check core + T6 happy-path + T7 ssh_policy + T8 cli register + T9 iptables yield + T10 nftables yield + T12 docs).

Hand off to user: announce that v0.17.0 is ready, ask whether to push to origin and publish to PyPI (do not push or publish without explicit consent — this is a hard-to-reverse external action).

---

## Self-Review Notes

**Spec coverage:** Each numbered section in the spec maps to a task —
- Spec §3.1 file layout: T1 (fixtures), T4-5 (Check), T2-3 (helpers), T7 (ssh_policy), T8 (cli), T12 (docs).
- Spec §3.4 detect + cross-talk: T2 (detect_firewalld), T3 (output_managed_by_firewalld), T9-10 (yield).
- Spec §3.5 command sequence: T5 (Check entry).
- Spec §4 components: T2-5.
- Spec §5 data flow: implied by T5-6 implementation.
- Spec §6 error handling: T5 covers all 8 error rows.
- Spec §7 ssh_policy: T7.
- Spec §8 testing: T1 (fixtures), T2-3-4-5-6 (unit tests), T7 (policy tests), T9-10 (cross-talk tests), T11 (full suite).
- Spec §9 release: T12.

**Placeholder scan:** Plan contains no TBD / TODO / "implement later" / "appropriate error handling". Every code step shows actual code. Every command step shows the literal command and expected output.

**Type / name consistency:** `FirewalldAuditCheck`, `parse_firewalld_zones`, `ZoneInfo`, `_FIREWALLD_TAKEOVER`, `output_managed_by_firewalld`, `detect_firewalld` — used identically across all tasks. Method signatures (e.g., `(stdout, *, backend)`) match between definition and call sites.

**Spec inaccuracy fixed:** Spec §8.2 referred to `tests/test_ssh_policy.py` for the policy validation tests, but that file does not exist — the actual location is `tests/audit/test_command_policy.py`. T7 uses the correct path.
