# log_review journald 支持 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:** Make `log_review` produce real findings on systemd `--user` deployments (Lightsail prod), reading via `journalctl` instead of returning `not_applicable`.

**Architecture:** New `LogSource` discriminated union on `PlatformProfile` distinguishes file-backed log directories from journald. `LogReviewCheck` dispatches on `log_source.kind`; the journald path fetches text via `journalctl --no-pager --output=cat` once per unit and runs fixed-string matching locally on the evaluator (matches existing `-F` semantics, no ReDoS surface, no remote pipes). New SSH policy entry for `journalctl` with three placeholder kinds (`<unit>`, `<since>`, `<lines>`) keeps the argv allowlist tight.

**Tech Stack:** Python 3.11+, pydantic v2, paramiko (existing SSHExecutor), pytest + pytest-asyncio. Spec: `docs/superpowers/specs/2026-05-07-log-review-journald-design.md` (commit `1f7e1c5`).

---

## File Structure

**Modify:**
- `src/agentsec/audit/platform_profile.py` — add `LogSource` union + `log_source` field + extend `materialize_paths`; set values on `LINUX` / `LINUX_USER_MODE` / `MACOS`
- `src/agentsec/audit/args_schema.py` — placeholder dispatch for `<unit>` / `<since>` / `<lines>`
- `src/agentsec/audit/ssh_policy.yaml` — new `journalctl` entry
- `src/agentsec/audit/checks/base.py` — `log_review_config` field on `CheckContext`
- `src/agentsec/audit/server_audit.py` — propagate the new ctx field through the per-check ctx clone
- `src/agentsec/audit/checks/log_review.py` — split into `_scan_files` (zero-behavior-change) + `_scan_journald` (new)
- `src/agentsec/config.py` — `LogReviewConfig` + `ServerAuditConfig.log_review`
- `src/agentsec/cli.py` — populate `ctx.log_review_config` in the two `CheckContext(...)` callsites
- `tests/audit/test_platform_profile.py` — `LogSource` validation + materialize cases
- `tests/audit/test_args_schema.py` — placeholder kind happy paths + rejects
- `tests/audit/checks/test_log_review.py` — extend with journald cases
- `tests/test_config_model.py` — `LogReviewConfig` defaults + bounds
- `tests/integration/test_openclaw_stage_e_smoke.py` — `LINUX_USER_MODE` matrix entry
- `CLAUDE.md` — note `log_source` + remove the journald-TODO line
- `CHANGELOG.md` — add `[Unreleased] / Added` entry

**Create:** none (entirely additive within existing modules)

---

## Task 1 — `LogSource` union on `PlatformProfile`

**Files:**
- Modify: `src/agentsec/audit/platform_profile.py`
- Test: `tests/audit/test_platform_profile.py`

- [ ] **Step 1.1: Add tests for `FileLogSource` / `JournaldLogSource` / `materialize_paths` extension**

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

```python
def test_file_log_source_validates():
    from agentsec.audit.platform_profile import FileLogSource
    src = FileLogSource(log_dir="~/Library/Logs/openclaw/")
    assert src.kind == "files"
    assert src.log_dir == "~/Library/Logs/openclaw/"


def test_journald_log_source_requires_at_least_one_unit():
    import pydantic

    from agentsec.audit.platform_profile import JournaldLogSource
    src = JournaldLogSource(user=True, units=("openclaw-gateway.service",))
    assert src.kind == "journald"
    assert src.user is True
    with pytest.raises(pydantic.ValidationError):
        JournaldLogSource(user=True, units=())


def test_linux_profile_has_file_log_source():
    from agentsec.audit.platform_profile import LINUX, FileLogSource
    assert isinstance(LINUX.log_source, FileLogSource)
    assert LINUX.log_source.log_dir == "/var/log/openclaw/"


def test_linux_user_mode_has_journald_log_source():
    from agentsec.audit.platform_profile import (
        LINUX_USER_MODE,
        JournaldLogSource,
    )
    src = LINUX_USER_MODE.log_source
    assert isinstance(src, JournaldLogSource)
    assert src.user is True
    assert "openclaw-gateway.service" in src.units
    assert "openclaw-node.service" in src.units


def test_macos_profile_has_file_log_source():
    from agentsec.audit.platform_profile import MACOS, FileLogSource
    assert isinstance(MACOS.log_source, FileLogSource)
    assert MACOS.log_source.log_dir == "~/Library/Logs/openclaw/"


def test_materialize_paths_expands_file_log_source_tilde():
    from agentsec.audit.platform_profile import MACOS, materialize_paths
    out = materialize_paths(MACOS, "/Users/alice")
    assert out.log_source.kind == "files"
    assert out.log_source.log_dir == "/Users/alice/Library/Logs/openclaw/"


def test_materialize_paths_passthrough_for_journald():
    from agentsec.audit.platform_profile import LINUX_USER_MODE, materialize_paths
    out = materialize_paths(LINUX_USER_MODE, "/home/ubuntu")
    assert out.log_source == LINUX_USER_MODE.log_source  # frozen, unchanged
```

- [ ] **Step 1.2: Run tests → expect failure**

```bash
.venv/bin/pytest tests/audit/test_platform_profile.py -q
```

Expected: 7 of the new tests fail with `ImportError: cannot import name 'FileLogSource'` (or similar).

- [ ] **Step 1.3: Implement `LogSource` + `log_source` field + extend `materialize_paths`**

Edit `src/agentsec/audit/platform_profile.py`. Add models above `class PlatformProfile`:

```python
from typing import Annotated, Literal
from pydantic import Field

class FileLogSource(BaseModel):
    model_config = ConfigDict(frozen=True)
    kind: Literal["files"] = "files"
    log_dir: str


class JournaldLogSource(BaseModel):
    model_config = ConfigDict(frozen=True)
    kind: Literal["journald"] = "journald"
    user: bool
    units: tuple[str, ...] = Field(min_length=1)


LogSource = Annotated[
    FileLogSource | JournaldLogSource,
    Field(discriminator="kind"),
]
```

Add field on `PlatformProfile`:

```python
class PlatformProfile(BaseModel):
    model_config = ConfigDict(frozen=True)

    name: Literal["linux", "macos"]
    paths: dict[str, str]
    services: dict[str, list[Argv]]
    listening_check: list[Argv]
    log_source: LogSource | None = None
```

Update three profile constants to set `log_source`:

```python
LINUX = PlatformProfile(
    ...,
    log_source=FileLogSource(log_dir="/var/log/openclaw/"),
)

LINUX_USER_MODE = PlatformProfile(
    ...,
    log_source=JournaldLogSource(
        user=True,
        units=("openclaw-gateway.service", "openclaw-node.service"),
    ),
)

MACOS = PlatformProfile(
    ...,
    log_source=FileLogSource(log_dir="~/Library/Logs/openclaw/"),
)
```

Extend `materialize_paths` to also materialize `FileLogSource.log_dir`:

```python
def materialize_paths(profile: PlatformProfile, remote_home: str) -> PlatformProfile:
    home = remote_home.rstrip("/")

    def _expand(p: str) -> str:
        if p.startswith("~/"):
            return f"{home}/{p[2:]}"
        return p

    new_paths = {k: _expand(v) for k, v in profile.paths.items()}
    new_log_source = profile.log_source
    if isinstance(new_log_source, FileLogSource):
        new_log_source = FileLogSource(log_dir=_expand(new_log_source.log_dir))
    return PlatformProfile(
        name=profile.name,
        paths=new_paths,
        services=profile.services,
        listening_check=profile.listening_check,
        log_source=new_log_source,
    )
```

- [ ] **Step 1.4: Run tests → expect pass**

```bash
.venv/bin/pytest tests/audit/test_platform_profile.py -q
```

Expected: all platform_profile tests pass.

- [ ] **Step 1.5: Commit**

```bash
git add src/agentsec/audit/platform_profile.py tests/audit/test_platform_profile.py
git commit -m "feat(platform-profile): add LogSource discriminated union"
```

---

## Task 2 — `LogReviewConfig` + wire into `ServerAuditConfig`

**Files:**
- Modify: `src/agentsec/config.py`
- Test: `tests/test_config_model.py`

- [ ] **Step 2.1: Add tests**

Append to `tests/test_config_model.py`:

```python
def test_log_review_config_defaults():
    from agentsec.config import LogReviewConfig
    cfg = LogReviewConfig()
    assert cfg.journald_since_hours == 24
    assert cfg.journald_max_lines == 10_000


def test_log_review_config_bounds():
    import pydantic
    from agentsec.config import LogReviewConfig
    with pytest.raises(pydantic.ValidationError):
        LogReviewConfig(journald_since_hours=0)
    with pytest.raises(pydantic.ValidationError):
        LogReviewConfig(journald_since_hours=200)  # > 168
    with pytest.raises(pydantic.ValidationError):
        LogReviewConfig(journald_max_lines=99)
    with pytest.raises(pydantic.ValidationError):
        LogReviewConfig(journald_max_lines=200_000)


def test_log_review_config_forbids_extra():
    import pydantic
    from agentsec.config import LogReviewConfig
    with pytest.raises(pydantic.ValidationError):
        LogReviewConfig(unknown_field=1)


def test_server_audit_config_has_log_review_default():
    from pathlib import Path
    from agentsec.config import ServerAuditConfig
    sa = ServerAuditConfig(host="h", user="u", key=Path("/k"))
    assert sa.log_review.journald_since_hours == 24
```

If `pytest` is not already imported in this file, add `import pytest` at the top.

- [ ] **Step 2.2: Run tests → expect failure**

```bash
.venv/bin/pytest tests/test_config_model.py -q -k log_review
```

Expected: 4 failures, "cannot import name 'LogReviewConfig'".

- [ ] **Step 2.3: Implement**

Edit `src/agentsec/config.py`. Add above `class ServerAuditConfig`:

```python
class LogReviewConfig(BaseModel):
    model_config = _FORBID
    journald_since_hours: int = Field(default=24, ge=1, le=168)
    journald_max_lines: int = Field(default=10_000, ge=100, le=100_000)
```

Add field to `ServerAuditConfig`:

```python
class ServerAuditConfig(BaseModel):
    model_config = _FORBID
    host: str
    user: str
    key: Path
    checks: Literal["all"] | list[str] = "all"
    ioc_db: Path = Path("./src/agentsec/audit/ioc/")
    ssh_audit_log: Path = Path("./audit-log.jsonl")
    ssh_policy: Path = Path("./src/agentsec/audit/ssh_policy.yaml")
    native_audit_argv: list[str] = Field(
        default_factory=lambda: ["openclaw", "security", "audit", "--json"]
    )
    log_review: LogReviewConfig = Field(default_factory=LogReviewConfig)
```

- [ ] **Step 2.4: Run tests → expect pass**

```bash
.venv/bin/pytest tests/test_config_model.py -q -k log_review
```

Expected: 4 passes.

- [ ] **Step 2.5: Commit**

```bash
git add src/agentsec/config.py tests/test_config_model.py
git commit -m "feat(config): add LogReviewConfig (journald_since_hours, journald_max_lines)"
```

---

## Task 3 — `CheckContext.log_review_config` field + orchestrator propagation

**Files:**
- Modify: `src/agentsec/audit/checks/base.py`, `src/agentsec/audit/server_audit.py`
- Test: `tests/audit/test_server_audit_orchestrator.py`

- [ ] **Step 3.1: Add test for ctx field propagation**

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

```python
@pytest.mark.asyncio
async def test_run_server_audit_propagates_log_review_config():
    """Per-check ctx clone must carry log_review_config."""
    from agentsec.audit.checks.base import Check, CheckContext, CheckStatus
    from agentsec.audit.platform_profile import LINUX
    from agentsec.audit.server_audit import run_server_audit
    from agentsec.config import LogReviewConfig

    captured: dict = {}

    class _Probe(Check):
        id = "probe"
        async def run(self, ctx):
            captured["log_review_config"] = ctx.log_review_config
            return []

    cfg = LogReviewConfig(journald_since_hours=72)
    parent_ctx = CheckContext(
        executor=None, redactor=None, ioc_dir=None, profile=LINUX,
        log_review_config=cfg,
    )
    await run_server_audit(checks=[_Probe()], ctx=parent_ctx)
    assert captured["log_review_config"] is cfg
```

If `pytest` / `pytest-asyncio` isn't imported, add `import pytest` and ensure `asyncio_mode = "auto"` is already in pyproject (it is).

- [ ] **Step 3.2: Run test → expect failure**

```bash
.venv/bin/pytest tests/audit/test_server_audit_orchestrator.py -q -k log_review_config
```

Expected: failure — `CheckContext.__init__()` got an unexpected keyword argument `log_review_config`.

- [ ] **Step 3.3: Implement**

Edit `src/agentsec/audit/checks/base.py`. Add import and field:

```python
from typing import Any, TYPE_CHECKING

if TYPE_CHECKING:
    from ..config import LogReviewConfig

@dataclass
class CheckContext:
    executor: Any
    redactor: Any
    ioc_dir: Path | None
    profile: PlatformProfile
    status: CheckStatus = field(default_factory=CheckStatus)
    log_review_config: "LogReviewConfig | None" = None
```

Wait — `agentsec.audit.checks.base` cannot import from `agentsec.config` (config imports CheckContext indirectly via the CLI; the cycle goes the other way). Use `TYPE_CHECKING` as shown so the runtime annotation is a forward-ref string.

Edit `src/agentsec/audit/server_audit.py:22-28` to propagate:

```python
        check_ctx = CheckContext(
            executor=ctx.executor,
            redactor=ctx.redactor,
            ioc_dir=ctx.ioc_dir,
            profile=ctx.profile,
            status=CheckStatus(),
            log_review_config=ctx.log_review_config,
        )
```

- [ ] **Step 3.4: Run test → expect pass**

```bash
.venv/bin/pytest tests/audit/test_server_audit_orchestrator.py -q
```

Expected: all pass (including new test).

- [ ] **Step 3.5: Commit**

```bash
git add src/agentsec/audit/checks/base.py src/agentsec/audit/server_audit.py \
        tests/audit/test_server_audit_orchestrator.py
git commit -m "feat(check-ctx): add log_review_config field + orchestrator propagation"
```

---

## Task 4 — CLI populates `ctx.log_review_config`

**Files:**
- Modify: `src/agentsec/cli.py`
- Test: relies on existing CLI integration tests in `tests/integration/test_openclaw_stage_e_smoke.py` (Task 11)

- [ ] **Step 4.1: Edit two `CheckContext(...)` callsites in `cli.py`**

`cli.py` has two construction sites:

**Site A — `cli.py:503` (the `agentsec server-audit` subcommand path, no yaml config):**

The `agentsec server-audit` CLI uses defaults (no yaml). Change:

```python
    ctx = CheckContext(
        executor=executor, redactor=redactor,
        ioc_dir=Path(__file__).parent / "audit" / "ioc",
        profile=profile,
    )
```

to:

```python
    from .config import LogReviewConfig
    ctx = CheckContext(
        executor=executor, redactor=redactor,
        ioc_dir=Path(__file__).parent / "audit" / "ioc",
        profile=profile,
        log_review_config=LogReviewConfig(),
    )
```

(If `LogReviewConfig` is already imported at module top, skip the inline `from`.)

**Site B — `cli.py:780` (the `agentsec evaluate` orchestrator path that does have a yaml):**

```python
    ctx = CheckContext(
        executor=executor, redactor=redactor,
        ioc_dir=sa.ioc_db, profile=profile,
    )
```

→

```python
    ctx = CheckContext(
        executor=executor, redactor=redactor,
        ioc_dir=sa.ioc_db, profile=profile,
        log_review_config=sa.log_review,
    )
```

`sa` here is the `ServerAuditConfig` instance, which now has `log_review` thanks to Task 2.

- [ ] **Step 4.2: Run targeted CLI tests**

```bash
.venv/bin/pytest tests/audit/test_cli_server_audit.py tests/integration/ -q
```

Expected: all existing tests still pass. Any new failures here mean the import or kwarg shape is wrong — fix before continuing.

- [ ] **Step 4.3: Commit**

```bash
git add src/agentsec/cli.py
git commit -m "feat(cli): pass log_review_config into both CheckContext sites"
```

---

## Task 5 — SSH policy placeholder dispatch (`<unit>` / `<since>` / `<lines>`)

**Files:**
- Modify: `src/agentsec/audit/args_schema.py`
- Test: `tests/audit/test_args_schema.py`

- [ ] **Step 5.1: Add tests**

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

```python
def test_journalctl_unit_placeholder_accepts_valid_unit():
    schema = {"allowed_argv": [
        ["journalctl", "--user", "--no-pager", "--output=cat",
         "-u", "<unit>", "--since", "<since>", "--lines", "<lines>"],
    ]}
    argv = ["journalctl", "--user", "--no-pager", "--output=cat",
            "-u", "openclaw-gateway.service",
            "--since", "24 hours ago",
            "--lines", "10000"]
    assert validate_args("journalctl", argv, schema, None) is None


@pytest.mark.parametrize("bad_unit", [
    "openclaw-gateway",                   # missing .service
    "openclaw gateway.service",           # space
    "../escape.service",                  # traversal
    "openclaw;rm -rf /.service",          # metachar
    "x" * 200 + ".service",               # length
    "",                                   # empty
])
def test_journalctl_rejects_bad_unit(bad_unit):
    schema = {"allowed_argv": [
        ["journalctl", "--user", "--no-pager", "--output=cat",
         "-u", "<unit>", "--since", "<since>", "--lines", "<lines>"],
    ]}
    argv = ["journalctl", "--user", "--no-pager", "--output=cat",
            "-u", bad_unit,
            "--since", "24 hours ago",
            "--lines", "10000"]
    assert validate_args("journalctl", argv, schema, None) is not None


@pytest.mark.parametrize("bad_since", [
    "0 hours ago",
    "12 minutes ago",
    "24 days ago",                        # days branch deliberately not allowed
    "24 hours;ls",
    "24 hours ago && ls",
    "10000 hours ago",                    # 5 digits, > 9999
    "two hours ago",
])
def test_journalctl_rejects_bad_since(bad_since):
    schema = {"allowed_argv": [
        ["journalctl", "--user", "--no-pager", "--output=cat",
         "-u", "<unit>", "--since", "<since>", "--lines", "<lines>"],
    ]}
    argv = ["journalctl", "--user", "--no-pager", "--output=cat",
            "-u", "openclaw-gateway.service",
            "--since", bad_since,
            "--lines", "10000"]
    assert validate_args("journalctl", argv, schema, None) is not None


@pytest.mark.parametrize("bad_lines", ["0", "9", "-1", "10.0", "1000000", "abc"])
def test_journalctl_rejects_bad_lines(bad_lines):
    schema = {"allowed_argv": [
        ["journalctl", "--user", "--no-pager", "--output=cat",
         "-u", "<unit>", "--since", "<since>", "--lines", "<lines>"],
    ]}
    argv = ["journalctl", "--user", "--no-pager", "--output=cat",
            "-u", "openclaw-gateway.service",
            "--since", "24 hours ago",
            "--lines", bad_lines]
    assert validate_args("journalctl", argv, schema, None) is not None
```

If `pytest` parametrize isn't imported, add `import pytest` to the file head.

- [ ] **Step 5.2: Run tests → expect failure**

```bash
.venv/bin/pytest tests/audit/test_args_schema.py -q -k journalctl
```

Expected: every new test fails with `does not match any allowed_argv` (because the placeholders are unknown — they fall through to the literal-string-equality branch, which never matches).

- [ ] **Step 5.3: Implement placeholder dispatch**

Edit `src/agentsec/audit/args_schema.py`. Add a regex table and dispatch in `_check_allowed_argv`:

```python
import re

_PLACEHOLDER_PATTERNS: dict[str, re.Pattern[str]] = {
    "<unit>":  re.compile(r"^[A-Za-z0-9._@-]{1,128}\.service$"),
    "<since>": re.compile(r"^[1-9][0-9]{0,3} hours? ago$"),
    "<lines>": re.compile(r"^[1-9][0-9]{1,5}$"),
}


def _check_allowed_argv(
    command: str,
    argv: list[str],
    allowed: list[list[str]],
    matcher: PathMatcher | None,
) -> str | None:
    for template in allowed:
        if len(template) != len(argv):
            continue
        ok = True
        for tmpl_tok, real_tok in zip(template, argv, strict=True):
            if tmpl_tok == "<allowed_path>":
                if matcher is None or matcher.matches(real_tok) is not None:
                    ok = False
                    break
            elif tmpl_tok in _PLACEHOLDER_PATTERNS:
                if not _PLACEHOLDER_PATTERNS[tmpl_tok].fullmatch(real_tok):
                    ok = False
                    break
            elif tmpl_tok != real_tok:
                ok = False
                break
        if ok:
            return None
    return f"argv {argv!r} does not match any allowed_argv for {command!r}"
```

- [ ] **Step 5.4: Run tests → expect pass**

```bash
.venv/bin/pytest tests/audit/test_args_schema.py -q
```

Expected: all pass (including journalctl placeholder cases).

- [ ] **Step 5.5: Commit**

```bash
git add src/agentsec/audit/args_schema.py tests/audit/test_args_schema.py
git commit -m "feat(ssh-policy): add <unit> / <since> / <lines> placeholder kinds"
```

---

## Task 6 — Add `journalctl` entry to `ssh_policy.yaml`

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

- [ ] **Step 6.1: Add an end-to-end policy test**

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

```python
def test_policy_accepts_journalctl_user_mode(policy):
    p = policy
    result = p.validate([
        "journalctl", "--user", "--no-pager", "--output=cat",
        "-u", "openclaw-gateway.service",
        "--since", "24 hours ago",
        "--lines", "10000",
    ])
    assert result.ok, result.reason


def test_policy_accepts_journalctl_system_mode(policy):
    p = policy
    result = p.validate([
        "journalctl", "--no-pager", "--output=cat",
        "-u", "openclaw.service",
        "--since", "1 hours ago",
        "--lines", "100",
    ])
    assert result.ok, result.reason


def test_policy_rejects_journalctl_with_grep_flag(policy):
    p = policy
    result = p.validate([
        "journalctl", "--user", "--no-pager", "--output=cat",
        "-u", "openclaw-gateway.service",
        "--grep", "secret",
        "--since", "24 hours ago",
        "--lines", "10000",
    ])
    assert not result.ok
```

The existing `policy` fixture at the top of `test_command_policy.py` loads `ssh_policy.yaml` directly — no change needed.

- [ ] **Step 6.2: Run tests → expect failure**

```bash
.venv/bin/pytest tests/audit/test_command_policy.py -q -k journalctl
```

Expected: 3 failures — `command 'journalctl' not in policy`.

- [ ] **Step 6.3: Edit `src/agentsec/audit/ssh_policy.yaml`**

Insert under `commands:` (right after the `openclaw:` block is fine):

```yaml
  journalctl:
    # systemd --user mode (Lightsail prod) routes logs to journald only —
    # no /var/log/openclaw/ directory exists. Pull the journal text once per
    # unit and grep on the evaluator side. --grep / --output=json / -p / -k
    # / -b / -S / -U / --system are NOT in any allowed_argv tuple, so they
    # are unreachable by construction; no explicit deny-list needed.
    allowed_argv:
      - ["journalctl", "--user", "--no-pager", "--output=cat",
         "-u", "<unit>", "--since", "<since>", "--lines", "<lines>"]
      - ["journalctl",            "--no-pager", "--output=cat",
         "-u", "<unit>", "--since", "<since>", "--lines", "<lines>"]
```

- [ ] **Step 6.4: Run tests → expect pass**

```bash
.venv/bin/pytest tests/audit/test_command_policy.py tests/audit/test_policy_yaml_loadable.py -q
```

Expected: all pass.

- [ ] **Step 6.5: Commit**

```bash
git add src/agentsec/audit/ssh_policy.yaml tests/audit/test_command_policy.py
git commit -m "feat(ssh-policy): allow journalctl with placeholder argv"
```

---

## Task 7 — Refactor `LogReviewCheck`: split files-vs-journald dispatcher

This task is a **pure refactor** with zero behavior change. Existing tests must keep passing.

**Files:**
- Modify: `src/agentsec/audit/checks/log_review.py`
- Test: `tests/audit/checks/test_log_review.py` (existing tests should pass unchanged)

- [ ] **Step 7.1: Replace `run()` with a dispatcher; move existing body into `_scan_files`**

Replace the body of `src/agentsec/audit/checks/log_review.py` with:

```python
"""log_review check (spec §3.2 #4).

Scans OpenClaw logs for attack-signature matches. Two backends:

* Files (LINUX, MACOS): list `paths.log_dir`, then `grep -i -c -F` each
  signature against each file. Output: 1 finding per (signature, log_path).
* Journald (LINUX_USER_MODE): `journalctl --no-pager --output=cat -u <unit>
  --since "<N> hours ago" --lines <N>` for each unit, then run fixed-string
  matching locally (no remote `--grep` — that is ERE regex and breaks the
  -F semantics; no remote pipe — policy rejects shell metacharacters).

Both backends return at most one severity=info "truncation" finding per
run when the scan is incomplete (file count cap / line cap / byte cap).
"""

from __future__ import annotations

from pathlib import Path

import yaml

from ..findings import Finding
from ..platform_profile import FileLogSource, JournaldLogSource
from .base import Check, CheckContext

_MAX_LOG_FILES = 200


class LogReviewCheck(Check):
    id = "log_review"
    _REQUIRED_PATHS: tuple[str, ...] = ()  # gate moves to log_source dispatch

    async def run(self, ctx: CheckContext) -> list[Finding]:
        src = ctx.profile.log_source
        if src is None:
            ctx.status.not_applicable("log_review:当前平台未暴露日志源")
            return []
        sigs = self._load_signatures(ctx)
        if sigs is None:
            return []  # _load_signatures already set ctx.status
        if isinstance(src, FileLogSource):
            return await self._scan_files(ctx, src, sigs)
        if isinstance(src, JournaldLogSource):
            return await self._scan_journald(ctx, src, sigs)
        # Defensive — discriminated union only allows the two above.
        ctx.status.skipped(f"log_review:未知 log_source kind {type(src).__name__}")
        return []

    def _load_signatures(self, ctx: CheckContext) -> list[dict] | None:
        if ctx.ioc_dir is None:
            ctx.status.skipped("log_review:ioc_dir 未配置")
            return None
        sigs_path = Path(ctx.ioc_dir) / "attack_signatures.yaml"
        if not sigs_path.exists():
            ctx.status.skipped(f"log_review:{sigs_path} 缺失")
            return None
        return yaml.safe_load(sigs_path.read_text()) or []

    async def _scan_files(
        self, ctx: CheckContext, src: FileLogSource, sigs: list[dict],
    ) -> list[Finding]:
        log_dir = src.log_dir
        list_res = ctx.executor.run([
            "find", log_dir, "-maxdepth", "2", "-type", "f", "-name", "*.log",
        ])
        if list_res.rejected or list_res.exit_code not in (0, 1):
            ctx.status.skipped(f"log_review:日志列表 find 退出码 {list_res.exit_code}")
            return []
        all_log_paths = [
            line.strip() for line in list_res.stdout.splitlines() if line.strip()
        ]
        truncated = max(0, len(all_log_paths) - _MAX_LOG_FILES)
        log_paths = all_log_paths[:_MAX_LOG_FILES]

        findings: list[Finding] = []
        if truncated:
            findings.append(self.make_finding(
                ctx,
                severity="info",
                title=(
                    f"log_review 仅扫描了 {log_dir} 下 {len(all_log_paths)} 个日志文件"
                    f"中的前 {_MAX_LOG_FILES} 个"
                ),
                description=(
                    f"`{log_dir}` 共 {len(all_log_paths)} 个日志文件，本次仅扫描了"
                    f"前 {_MAX_LOG_FILES} 个，{truncated} 个被跳过。`find` 调用的 1 MiB"
                    f" stdout 上限可能在更上游已截断列表 —— 请把 log_dir 收窄到更小的"
                    f"子目录，或把更早的日志轮转出审计范围以获得完整覆盖。"
                ),
                evidence={
                    "log_dir": log_dir,
                    "scanned": _MAX_LOG_FILES,
                    "skipped": truncated,
                },
                remediation=(
                    "将 log_dir 收窄到更小的子目录（如仅当天日志），"
                    "或将更早的日志轮转出审计范围。"
                ),
                threat_refs=[],
            ))
        for sig in sigs:
            for log_path in log_paths:
                res = ctx.executor.run([
                    "grep", "-i", "-c", "-F", sig["pattern"], log_path,
                ])
                if res.rejected:
                    continue
                try:
                    count = int(res.stdout.strip() or "0")
                except ValueError:
                    continue
                if count <= 0:
                    continue
                findings.append(self.make_finding(
                    ctx,
                    severity=sig["severity"],
                    title=f"攻击特征 {sig['id']} 在 {log_path} 命中 {count} 次",
                    description=sig["claim"],
                    evidence={
                        "signature_id": sig["id"],
                        "log_path": log_path,
                        "match_count": count,
                    },
                    remediation=(
                        "查阅相关日志条目，结合时间线进行关联分析；"
                        "如涉及任何凭据，请考虑轮换。"
                    ),
                    threat_refs=list(sig.get("threat_refs", [])),
                ))
        return findings

    async def _scan_journald(
        self, ctx: CheckContext, src: JournaldLogSource, sigs: list[dict],
    ) -> list[Finding]:
        # Implemented in Task 8 — for now return empty so the dispatcher's
        # journald branch is reachable but inert.
        ctx.status.skipped("log_review:journald 路径尚未实现（占位）")
        return []
```

- [ ] **Step 7.2: Run all log_review tests → existing ones must still pass**

```bash
.venv/bin/pytest tests/audit/checks/test_log_review.py -q
```

Expected: all 4 existing tests pass. The `not_applicable` test still passes because its profile has no `log_source` field set — the dispatcher hits the `src is None` branch with the same outcome.

- [ ] **Step 7.3: Commit**

```bash
git add src/agentsec/audit/checks/log_review.py
git commit -m "refactor(log_review): split into _scan_files / _scan_journald dispatcher"
```

---

## Task 8 — Implement `_scan_journald` (single-unit happy path)

**Files:**
- Modify: `src/agentsec/audit/checks/log_review.py`
- Test: `tests/audit/checks/test_log_review.py`

- [ ] **Step 8.1: Add a single-unit happy-path test**

Append to `tests/audit/checks/test_log_review.py`:

```python
@pytest.mark.asyncio
async def test_log_review_journald_single_unit_match():
    """LINUX_USER_MODE → one journalctl call per unit; pattern hits become Findings."""
    from agentsec.audit.platform_profile import (
        JournaldLogSource,
        PlatformProfile,
    )
    from agentsec.config import LogReviewConfig

    sigs = yaml.safe_load((IOC_DIR / "attack_signatures.yaml").read_text())
    pattern = sigs[0]["pattern"]
    sig_id = sigs[0]["id"]

    journal_text = (
        "2026-05-07 some startup line\n"
        f"2026-05-07 hit: {pattern}\n"
        "2026-05-07 unrelated\n"
        f"2026-05-07 another {pattern.upper()} occurrence\n"
    )
    expected_argv = [
        "journalctl", "--user", "--no-pager", "--output=cat",
        "-u", "openclaw-gateway.service",
        "--since", "24 hours ago",
        "--lines", "10000",
    ]

    class _Executor:
        def __init__(self): self.calls = []
        def run(self, argv):
            self.calls.append(argv)
            if argv == expected_argv:
                return _ok(argv, journal_text)
            return _ok(argv, "", exit_code=1)

    profile = PlatformProfile(
        name="linux",
        paths={"openclaw_home": "/home/u/.openclaw/"},
        services={"list": [["systemctl", "--user", "list-units"]]},
        listening_check=[["ss", "-tlnp"]],
        log_source=JournaldLogSource(
            user=True, units=("openclaw-gateway.service",),
        ),
    )
    ctx = CheckContext(
        executor=_Executor(), redactor=Redactor(), ioc_dir=IOC_DIR,
        profile=profile, status=CheckStatus(),
        log_review_config=LogReviewConfig(),
    )
    findings = await LogReviewCheck().run(ctx)
    matching = [f for f in findings if f.evidence.get("signature_id") == sig_id]
    assert len(matching) == 1
    assert matching[0].evidence["match_count"] == 2  # case-insensitive
    assert matching[0].evidence["unit"] == "openclaw-gateway.service"
    assert matching[0].evidence["journald_user"] is True
    assert matching[0].evidence["since_hours"] == 24
    assert matching[0].evidence["lines_cap"] == 10_000


@pytest.mark.asyncio
async def test_log_review_journald_no_config_uses_defaults():
    """Missing log_review_config → fall back to LogReviewConfig() defaults
    so the check is robust to construction sites that forget to wire it."""
    from agentsec.audit.platform_profile import (
        JournaldLogSource,
        PlatformProfile,
    )

    journal_text = "2026-05-07 quiet line\n"
    seen_argv: list[list[str]] = []

    class _Executor:
        def run(self, argv):
            seen_argv.append(argv)
            return _ok(argv, journal_text)

    profile = PlatformProfile(
        name="linux", paths={"openclaw_home": "/home/u/.openclaw/"},
        services={"list": [["systemctl", "--user", "list-units"]]},
        listening_check=[["ss", "-tlnp"]],
        log_source=JournaldLogSource(user=True, units=("x.service",)),
    )
    ctx = CheckContext(
        executor=_Executor(), redactor=Redactor(), ioc_dir=IOC_DIR,
        profile=profile, status=CheckStatus(),
        # log_review_config intentionally omitted → defaults to None
    )
    await LogReviewCheck().run(ctx)
    # Default since=24h, lines=10000
    assert seen_argv == [[
        "journalctl", "--user", "--no-pager", "--output=cat",
        "-u", "x.service",
        "--since", "24 hours ago",
        "--lines", "10000",
    ]]
```

- [ ] **Step 8.2: Run tests → expect failure**

```bash
.venv/bin/pytest tests/audit/checks/test_log_review.py -q -k journald
```

Expected: both new tests fail — current `_scan_journald` returns `[]` and sets `skipped`.

- [ ] **Step 8.3: Implement `_scan_journald` body**

Replace the placeholder `_scan_journald` from Task 7 with:

```python
    async def _scan_journald(
        self, ctx: CheckContext, src: JournaldLogSource, sigs: list[dict],
    ) -> list[Finding]:
        # Defensive default: callers that forget to populate the field
        # still get a sane 24h / 10000-line scan instead of crashing.
        from ..config import LogReviewConfig
        cfg = ctx.log_review_config or LogReviewConfig()
        since = f"{cfg.journald_since_hours} hours ago"
        lines_cap = cfg.journald_max_lines

        findings: list[Finding] = []
        for unit in src.units:
            argv = [
                "journalctl",
                *(["--user"] if src.user else []),
                "--no-pager", "--output=cat",
                "-u", unit,
                "--since", since,
                "--lines", str(lines_cap),
            ]
            res = ctx.executor.run(argv)
            if res.rejected or res.exit_code != 0:
                # Single unit failure does not abort the scan — Task 9 adds
                # the multi-unit-skip logic. For now this branch leaves zero
                # findings for the failing unit.
                continue
            findings.extend(self._match_journal_text(
                ctx, src=src, unit=unit, text=res.stdout,
                since_hours=cfg.journald_since_hours,
                lines_cap=lines_cap, sigs=sigs,
            ))
        return findings

    def _match_journal_text(
        self, ctx: CheckContext, *,
        src: JournaldLogSource, unit: str, text: str,
        since_hours: int, lines_cap: int, sigs: list[dict],
    ) -> list[Finding]:
        """Run case-insensitive fixed-string matching on `text` for every
        signature. Mirrors `grep -i -c -F` semantics so file-backed and
        journald-backed scans produce comparable findings."""
        lowered_lines = [line.lower() for line in text.splitlines()]
        out: list[Finding] = []
        for sig in sigs:
            needle = sig["pattern"].lower()
            count = sum(1 for ln in lowered_lines if needle in ln)
            if count <= 0:
                continue
            out.append(self.make_finding(
                ctx,
                severity=sig["severity"],
                title=f"攻击特征 {sig['id']} 在 unit {unit} 命中 {count} 次",
                description=sig["claim"],
                evidence={
                    "signature_id": sig["id"],
                    "unit": unit,
                    "match_count": count,
                    "journald_user": src.user,
                    "since_hours": since_hours,
                    "lines_cap": lines_cap,
                },
                remediation=(
                    "查阅相关 journald 条目（journalctl --user -u <unit>），"
                    "结合时间线进行关联分析；如涉及任何凭据，请考虑轮换。"
                ),
                threat_refs=list(sig.get("threat_refs", [])),
            ))
        return out
```

- [ ] **Step 8.4: Run tests → expect pass**

```bash
.venv/bin/pytest tests/audit/checks/test_log_review.py -q
```

Expected: all log_review tests pass (existing 4 + new 2).

- [ ] **Step 8.5: Commit**

```bash
git add src/agentsec/audit/checks/log_review.py tests/audit/checks/test_log_review.py
git commit -m "feat(log_review): implement journald single-unit scan path"
```

---

## Task 9 — Multi-unit + skip-failed handling

**Files:**
- Modify: `src/agentsec/audit/checks/log_review.py`
- Test: `tests/audit/checks/test_log_review.py`

- [ ] **Step 9.1: Add tests**

Append to `tests/audit/checks/test_log_review.py`:

```python
@pytest.mark.asyncio
async def test_log_review_journald_skips_failed_unit_continues_others():
    """unit A succeeds, unit B fails → A still produces findings; B silently skipped."""
    from agentsec.audit.platform_profile import (
        JournaldLogSource,
        PlatformProfile,
    )
    from agentsec.config import LogReviewConfig

    sigs = yaml.safe_load((IOC_DIR / "attack_signatures.yaml").read_text())
    pattern = sigs[0]["pattern"]

    class _Executor:
        def run(self, argv):
            unit_idx = argv.index("-u") + 1
            unit = argv[unit_idx]
            if unit == "openclaw-gateway.service":
                return _ok(argv, f"hit: {pattern}\n")
            return _ok(argv, "", exit_code=1)  # Unit not found

    profile = PlatformProfile(
        name="linux", paths={"openclaw_home": "/h/.openclaw/"},
        services={"list": [["systemctl", "--user", "list-units"]]},
        listening_check=[["ss", "-tlnp"]],
        log_source=JournaldLogSource(
            user=True,
            units=("openclaw-gateway.service", "openclaw-node.service"),
        ),
    )
    ctx = CheckContext(
        executor=_Executor(), redactor=Redactor(), ioc_dir=IOC_DIR,
        profile=profile, status=CheckStatus(),
        log_review_config=LogReviewConfig(),
    )
    findings = await LogReviewCheck().run(ctx)
    units_with_findings = {f.evidence["unit"] for f in findings if "unit" in f.evidence}
    assert units_with_findings == {"openclaw-gateway.service"}
    # Status should remain non-skipped (at least one unit succeeded).
    assert not ctx.status.is_skipped


@pytest.mark.asyncio
async def test_log_review_journald_all_units_fail_skips_check():
    from agentsec.audit.platform_profile import (
        JournaldLogSource,
        PlatformProfile,
    )
    from agentsec.config import LogReviewConfig

    class _Executor:
        def run(self, argv):
            return _ok(argv, "", exit_code=1)

    profile = PlatformProfile(
        name="linux", paths={"openclaw_home": "/h/.openclaw/"},
        services={"list": [["systemctl", "--user", "list-units"]]},
        listening_check=[["ss", "-tlnp"]],
        log_source=JournaldLogSource(
            user=True,
            units=("a.service", "b.service"),
        ),
    )
    ctx = CheckContext(
        executor=_Executor(), redactor=Redactor(), ioc_dir=IOC_DIR,
        profile=profile, status=CheckStatus(),
        log_review_config=LogReviewConfig(),
    )
    findings = await LogReviewCheck().run(ctx)
    assert findings == []
    assert ctx.status.is_skipped
    assert "所有 unit" in (ctx.status.skip_reason or "")
```

- [ ] **Step 9.2: Run tests → expect failure**

```bash
.venv/bin/pytest tests/audit/checks/test_log_review.py -q -k "all_units_fail or skips_failed"
```

Expected: `test_log_review_journald_all_units_fail_skips_check` fails (currently no skip is set).

- [ ] **Step 9.3: Implement — track per-unit success and skip if all failed**

Modify `_scan_journald` to count successes:

```python
    async def _scan_journald(
        self, ctx: CheckContext, src: JournaldLogSource, sigs: list[dict],
    ) -> list[Finding]:
        from ..config import LogReviewConfig
        cfg = ctx.log_review_config or LogReviewConfig()
        since = f"{cfg.journald_since_hours} hours ago"
        lines_cap = cfg.journald_max_lines

        findings: list[Finding] = []
        successes = 0
        for unit in src.units:
            argv = [
                "journalctl",
                *(["--user"] if src.user else []),
                "--no-pager", "--output=cat",
                "-u", unit,
                "--since", since,
                "--lines", str(lines_cap),
            ]
            res = ctx.executor.run(argv)
            if res.rejected or res.exit_code != 0:
                continue
            successes += 1
            findings.extend(self._match_journal_text(
                ctx, src=src, unit=unit, text=res.stdout,
                since_hours=cfg.journald_since_hours,
                lines_cap=lines_cap, sigs=sigs,
            ))
        if successes == 0:
            ctx.status.skipped("log_review:所有 unit 均不可读")
            return []
        return findings
```

- [ ] **Step 9.4: Run tests → expect pass**

```bash
.venv/bin/pytest tests/audit/checks/test_log_review.py -q
```

Expected: all log_review tests pass.

- [ ] **Step 9.5: Commit**

```bash
git add src/agentsec/audit/checks/log_review.py tests/audit/checks/test_log_review.py
git commit -m "feat(log_review): journald multi-unit skip-failed handling"
```

---

## Task 10 — Truncation detection (line cap + byte cap)

**Files:**
- Modify: `src/agentsec/audit/checks/log_review.py`
- Test: `tests/audit/checks/test_log_review.py`

- [ ] **Step 10.1: Add tests**

Append:

```python
@pytest.mark.asyncio
async def test_log_review_journald_lines_cap_emits_truncation_finding():
    from agentsec.audit.platform_profile import (
        JournaldLogSource,
        PlatformProfile,
    )
    from agentsec.config import LogReviewConfig

    cfg = LogReviewConfig(journald_max_lines=100)
    text = "\n".join(f"line-{i}" for i in range(100)) + "\n"  # exactly 100 lines

    class _Executor:
        def run(self, argv):
            return _ok(argv, text)

    profile = PlatformProfile(
        name="linux", paths={"openclaw_home": "/h/.openclaw/"},
        services={"list": [["systemctl", "--user", "list-units"]]},
        listening_check=[["ss", "-tlnp"]],
        log_source=JournaldLogSource(user=True, units=("x.service",)),
    )
    ctx = CheckContext(
        executor=_Executor(), redactor=Redactor(), ioc_dir=IOC_DIR,
        profile=profile, status=CheckStatus(),
        log_review_config=cfg,
    )
    findings = await LogReviewCheck().run(ctx)
    truncs = [f for f in findings if f.severity == "info" and "覆盖" in f.title]
    assert len(truncs) == 1
    assert truncs[0].evidence["reason"] == "lines_cap"


@pytest.mark.asyncio
async def test_log_review_journald_byte_cap_emits_truncation_finding():
    from agentsec.audit.platform_profile import (
        JournaldLogSource,
        PlatformProfile,
    )
    from agentsec.config import LogReviewConfig

    cfg = LogReviewConfig(journald_max_lines=100_000)
    # max_output_bytes default in policy is 1 MiB. 0.96 MiB worth of lines
    # crosses the 0.95 threshold.
    bigline = "x" * 100
    n_lines = int(1_048_576 * 0.96 / (len(bigline) + 1))
    text = ("\n".join([bigline] * n_lines)) + "\n"

    class _Executor:
        def run(self, argv):
            res = _ok(argv, text)
            return res

    profile = PlatformProfile(
        name="linux", paths={"openclaw_home": "/h/.openclaw/"},
        services={"list": [["systemctl", "--user", "list-units"]]},
        listening_check=[["ss", "-tlnp"]],
        log_source=JournaldLogSource(user=True, units=("x.service",)),
    )
    ctx = CheckContext(
        executor=_Executor(), redactor=Redactor(), ioc_dir=IOC_DIR,
        profile=profile, status=CheckStatus(),
        log_review_config=cfg,
    )
    findings = await LogReviewCheck().run(ctx)
    truncs = [f for f in findings if f.severity == "info" and "覆盖" in f.title]
    assert len(truncs) == 1
    assert truncs[0].evidence["reason"] == "byte_cap"


@pytest.mark.asyncio
async def test_log_review_journald_dual_truncation_emits_only_one_finding():
    """Both signals fire on the same unit → still 1 truncation finding."""
    from agentsec.audit.platform_profile import (
        JournaldLogSource,
        PlatformProfile,
    )
    from agentsec.config import LogReviewConfig

    cfg = LogReviewConfig(journald_max_lines=10_000)
    bigline = "x" * 110
    n_lines = max(int(1_048_576 * 0.96 / (len(bigline) + 1)), 10_000)
    text = ("\n".join([bigline] * n_lines)) + "\n"

    class _Executor:
        def run(self, argv):
            return _ok(argv, text)

    profile = PlatformProfile(
        name="linux", paths={"openclaw_home": "/h/.openclaw/"},
        services={"list": [["systemctl", "--user", "list-units"]]},
        listening_check=[["ss", "-tlnp"]],
        log_source=JournaldLogSource(user=True, units=("x.service",)),
    )
    ctx = CheckContext(
        executor=_Executor(), redactor=Redactor(), ioc_dir=IOC_DIR,
        profile=profile, status=CheckStatus(),
        log_review_config=cfg,
    )
    findings = await LogReviewCheck().run(ctx)
    truncs = [f for f in findings if f.severity == "info" and "覆盖" in f.title]
    assert len(truncs) == 1
```

- [ ] **Step 10.2: Run tests → expect failure**

```bash
.venv/bin/pytest tests/audit/checks/test_log_review.py -q -k truncation
```

Expected: 3 failures.

- [ ] **Step 10.3: Implement truncation detection**

Add a helper near the top of `log_review.py`:

```python
_MAX_OUTPUT_BYTES = 1_048_576    # mirrors ssh_policy.yaml max_output_bytes
_BYTE_TRUNCATION_THRESHOLD = 0.95  # ≥95% of cap is treated as truncated
```

Modify `_scan_journald` to detect truncation per-unit and emit at most one info finding per run:

```python
    async def _scan_journald(
        self, ctx: CheckContext, src: JournaldLogSource, sigs: list[dict],
    ) -> list[Finding]:
        from ..config import LogReviewConfig
        cfg = ctx.log_review_config or LogReviewConfig()
        since = f"{cfg.journald_since_hours} hours ago"
        lines_cap = cfg.journald_max_lines

        findings: list[Finding] = []
        successes = 0
        truncation_reason: str | None = None  # "lines_cap" or "byte_cap"
        truncation_unit: str | None = None
        for unit in src.units:
            argv = [
                "journalctl",
                *(["--user"] if src.user else []),
                "--no-pager", "--output=cat",
                "-u", unit,
                "--since", since,
                "--lines", str(lines_cap),
            ]
            res = ctx.executor.run(argv)
            if res.rejected or res.exit_code != 0:
                continue
            successes += 1
            text = res.stdout

            # Truncation signal: track first observed reason; later units do
            # not overwrite (one info finding per run is enough).
            if truncation_reason is None:
                line_count = text.count("\n")
                byte_count = len(text.encode("utf-8"))
                if line_count >= lines_cap:
                    truncation_reason = "lines_cap"
                    truncation_unit = unit
                elif byte_count >= int(_MAX_OUTPUT_BYTES * _BYTE_TRUNCATION_THRESHOLD):
                    truncation_reason = "byte_cap"
                    truncation_unit = unit

            findings.extend(self._match_journal_text(
                ctx, src=src, unit=unit, text=text,
                since_hours=cfg.journald_since_hours,
                lines_cap=lines_cap, sigs=sigs,
            ))

        if successes == 0:
            ctx.status.skipped("log_review:所有 unit 均不可读")
            return []

        if truncation_reason is not None:
            findings.append(self.make_finding(
                ctx,
                severity="info",
                title=(
                    f"log_review journald 扫描覆盖不全（{truncation_reason}）"
                ),
                description=(
                    f"unit `{truncation_unit}` 的 journald 输出在本次扫描中"
                    f"撞到{'行数' if truncation_reason == 'lines_cap' else '字节'}"
                    f"上限，意味着窗口内可能有更早的日志条目未被读到。"
                    f"请提高 `journald_max_lines` 或缩短 `journald_since_hours`，"
                    f"或拆分到多个时间段重跑。"
                ),
                evidence={
                    "reason": truncation_reason,
                    "unit": truncation_unit,
                    "since_hours": cfg.journald_since_hours,
                    "lines_cap": lines_cap,
                },
                remediation=(
                    "调整 server_audit.log_review.journald_since_hours 或 "
                    "journald_max_lines，必要时分段扫描。"
                ),
                threat_refs=[],
            ))
        return findings
```

- [ ] **Step 10.4: Run tests → expect pass**

```bash
.venv/bin/pytest tests/audit/checks/test_log_review.py -q
```

Expected: all log_review tests pass.

- [ ] **Step 10.5: Commit**

```bash
git add src/agentsec/audit/checks/log_review.py tests/audit/checks/test_log_review.py
git commit -m "feat(log_review): journald truncation detection (lines_cap + byte_cap)"
```

---

## Task 11 — Stage E smoke: `LINUX_USER_MODE` end-to-end

**Files:**
- Modify: `tests/integration/test_openclaw_stage_e_smoke.py`

- [ ] **Step 11.1: Read the existing smoke shape**

```bash
grep -n "LINUX\|profile" tests/integration/test_openclaw_stage_e_smoke.py | head -30
```

Use the existing fixtures / helpers; do not rebuild the whole orchestrator wiring from scratch.

- [ ] **Step 11.2: Add test that runs `LogReviewCheck` against `LINUX_USER_MODE`**

Append to `tests/integration/test_openclaw_stage_e_smoke.py`:

```python
@pytest.mark.asyncio
async def test_log_review_runs_on_linux_user_mode_via_journald():
    """LINUX_USER_MODE → log_review must NOT be not_applicable; it must call
    journalctl and produce findings (or 0 findings + 0 truncation, as long as
    the status is not 'not_applicable')."""
    import yaml as _yaml

    from agentsec.audit.checks.base import CheckContext, CheckStatus
    from agentsec.audit.checks.log_review import LogReviewCheck
    from agentsec.audit.ioc import IOC_DIR
    from agentsec.audit.platform_profile import LINUX_USER_MODE
    from agentsec.audit.redactor import Redactor
    from agentsec.audit.types import RunResult
    from agentsec.config import LogReviewConfig

    sigs = _yaml.safe_load((IOC_DIR / "attack_signatures.yaml").read_text())
    pattern = sigs[0]["pattern"]
    journal_text = f"line a\n{pattern}\nline c\n"

    class _Executor:
        def run(self, argv):
            return RunResult(
                argv=argv, command_sha256="x"*64, exit_code=0,
                stdout=journal_text, stderr="",
                duration_ms=1.0, bytes_out=len(journal_text.encode()),
                rejected=False, reject_reason=None,
            )

    ctx = CheckContext(
        executor=_Executor(), redactor=Redactor(), ioc_dir=IOC_DIR,
        profile=LINUX_USER_MODE, status=CheckStatus(),
        log_review_config=LogReviewConfig(),
    )
    findings = await LogReviewCheck().run(ctx)
    assert not ctx.status.is_not_applicable
    # Two units → at least one finding per signature hit per unit
    assert any("unit" in f.evidence for f in findings)
```

- [ ] **Step 11.3: Run integration tests**

```bash
.venv/bin/pytest tests/integration/test_openclaw_stage_e_smoke.py -q
```

Expected: all pass.

- [ ] **Step 11.4: Commit**

```bash
git add tests/integration/test_openclaw_stage_e_smoke.py
git commit -m "test(integration): log_review runs on LINUX_USER_MODE via journald"
```

---

## Task 12 — Sample drift guard + full regression

The committed sample at `docs/samples/report-2026-04-28/` was generated with the `LINUX` profile (file-based). Refactor preserves `_scan_files` behavior exactly, so the drift guard should still pass — verify, do not refresh blindly.

- [ ] **Step 12.1: Run the sample drift guard**

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

Expected: all pass. If `test_committed_sample_matches_pipeline_output` fails, **stop** — investigate whether `_scan_files` actually drifted (it should not have). Only refresh with `AGENTSEC_REFRESH_SAMPLE=1` if the change is intentional and reviewable in the diff.

- [ ] **Step 12.2: Full repo regression**

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

Expected: pytest 911+ pass (10 new from this plan: 7 platform, 4 config, 1 orchestrator, 11 args_schema, 3 policy, 7 log_review journald, 1 integration — adjust as you land each task), ruff clean, pyright clean.

- [ ] **Step 12.3: If anything fails, fix and re-run; do not commit a half-broken state.**

---

## Task 13 — Docs sync (CLAUDE.md + CHANGELOG.md)

**Files:**
- Modify: `CLAUDE.md`, `CHANGELOG.md`

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

Find the "Stage E additions" block (under `## Server-audit module`). Add a bullet near the existing platform-paths bullet:

```markdown
- **`PlatformProfile.log_source`** is the source of truth for where logs
  live. `FileLogSource(log_dir=...)` for `LINUX` / `MACOS`;
  `JournaldLogSource(user=True, units=(...))` for `LINUX_USER_MODE`.
  `LogReviewCheck` dispatches on the kind. Adding a new platform with a
  novel log surface (e.g. macOS unified logging) means adding a third
  variant + extending the dispatcher.
```

Then find the line `log_review: read journald via …` in the same memory file (also referenced from project memory) and remove it from the open-TODO list — the work is done.

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

Edit the `## [Unreleased]` section. Add under `### Added`:

```markdown
- **`log_review` 接 journald** — `systemd --user` 模式部署（如 Lightsail
  prod）不再被打成 `not_applicable`。`PlatformProfile` 增加 discriminated
  `log_source` 字段（`FileLogSource | JournaldLogSource`）；
  `LINUX_USER_MODE` 走 `journalctl --no-pager --output=cat -u <unit>
  --since <N hours ago> --lines <N>`，evaluator 本地 fixed-string 匹配
  保持 `-F` 语义。新增 `ServerAuditConfig.log_review.journald_since_hours`
  / `journald_max_lines`（默认 24h / 10000 行；范围 1–168 / 100–100000）。
  SSH policy 加入 `journalctl` 命令、三个新占位符 kind（`<unit>` /
  `<since>` / `<lines>`），不放过 `--grep` / `--output=json` / `-p` /
  `-k` 等扩面 flag。
```

- [ ] **Step 13.3: Commit**

```bash
git add CLAUDE.md CHANGELOG.md
git commit -m "docs: log_review journald support — CLAUDE.md + CHANGELOG"
```

---

## Acceptance Checklist (run after Task 13)

- [ ] `git log --oneline -15` shows ~13 small focused commits, no squashes
- [ ] `.venv/bin/pytest -q` — all green
- [ ] `.venv/bin/ruff check src/ tests/` — clean
- [ ] `.venv/bin/pyright` — 0 errors / 0 warnings
- [ ] `tests/integration/test_openclaw_mock.py::test_committed_sample_matches_pipeline_output` — passes (no sample refresh needed)
- [ ] Lightsail prod replay (`memory: project_openclaw_lightsail_prod.md`) produces ≥1 `log_review` finding (or 0 findings + 0 truncation, but `not_applicable` is gone). Optional manual verification.
- [ ] `git push` — only when CI is expected to be green
