# Plugin AST Danger-Pattern Scan — 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 `PluginStaticCheck` to scan installed OpenClaw skill Python source files for five categories of dangerous API calls using Python's built-in `ast` module.

**Architecture:** SSH `find` discovers `.py` files under `~/.openclaw/skills/`, SSH `cat` retrieves each file, Python `ast.walk` on the evaluator detects dangerous calls — `_DANGER_CALLS` for Call-based patterns and `_DANGER_ATTRS` for bare attribute access (`os.environ`). One `high`-severity finding per `(skill_file, danger_category)`. IOC fingerprint matching (existing, `critical`) runs independently.

**Tech Stack:** Python 3.12, `ast` (stdlib), `pytest-asyncio`

---

## File Map

| File | Change |
|------|--------|
| `src/agentsec/audit/platform_profile.py` | Add `skills_dir` to `LINUX` and `MACOS` profiles |
| `src/agentsec/audit/checks/plugin_static.py` | Add `_DANGER_CALLS`, `_DANGER_ATTRS`, `_scan_source()`, `_ast_scan()`; call from `run()` |
| `tests/audit/checks/test_plugin_static.py` | Add 4 new test cases + executor helper |

---

### Task 1: Add `skills_dir` to PlatformProfile

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

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

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

```python
from agentsec.audit.platform_profile import LINUX, MACOS


def test_platform_profile_has_skills_dir():
    assert "skills_dir" in LINUX.paths
    assert LINUX.paths["skills_dir"] == "~/.openclaw/skills/"
    assert "skills_dir" in MACOS.paths
    assert MACOS.paths["skills_dir"].endswith("/skills/")
```

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

```bash
cd /Users/roger/Work/AgentSec && .venv/bin/pytest tests/audit/checks/test_plugin_static.py::test_platform_profile_has_skills_dir -v
```

Expected: `FAILED` — `AssertionError: assert "skills_dir" in {...}`

- [ ] **Step 3: Add `skills_dir` to both profiles**

In `src/agentsec/audit/platform_profile.py`, update `LINUX`:

```python
LINUX = PlatformProfile(
    name="linux",
    paths={
        "openclaw_home": "~/.openclaw/",
        "memory_file": "~/.openclaw/MEMORY.md",
        "log_dir": "/var/log/openclaw/",
        "systemd_unit": "/etc/systemd/system/openclaw.service",
        "skills_dir": "~/.openclaw/skills/",
    },
    services={
        "list":   [["systemctl", "list-units", "--type=service", "openclaw.service"]],
        "status": [["systemctl", "status", "openclaw.service"]],
        "show":   [["systemctl", "show", "openclaw.service"]],
    },
    listening_check=[["ss", "-tlnp"], ["ss", "-ulnp"]],
)
```

Update `MACOS`:

```python
MACOS = PlatformProfile(
    name="macos",
    paths={
        "openclaw_home": "~/Library/Application Support/openclaw/",
        "memory_file": "~/Library/Application Support/openclaw/MEMORY.md",
        "log_dir": "~/Library/Logs/openclaw/",
        "launch_daemon": "/Library/LaunchDaemons/ai.openclaw.gateway.plist",
        "skills_dir": "~/Library/Application Support/openclaw/skills/",
    },
    services={
        "list":  [["launchctl", "list"]],
        "print": [["launchctl", "print", "system/ai.openclaw.gateway"]],
    },
    listening_check=[["lsof", "-i", "-n", "-P"]],
)
```

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

```bash
.venv/bin/pytest tests/audit/checks/test_plugin_static.py::test_platform_profile_has_skills_dir -v
```

Expected: `PASSED`

- [ ] **Step 5: Commit**

```bash
git add src/agentsec/audit/platform_profile.py tests/audit/checks/test_plugin_static.py
git commit -m "feat(audit): add skills_dir to PlatformProfile for plugin AST scan

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

### Task 2: Tests for `_scan_source()` pure function

**Files:**
- Modify: `tests/audit/checks/test_plugin_static.py`

These tests target `_scan_source` directly — no SSH executor needed.

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

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

```python
from agentsec.audit.checks.plugin_static import PluginStaticCheck


def test_scan_source_detects_subprocess_run():
    source = "import subprocess\nsubprocess.run(['ls', '-la'])\n"
    hits = PluginStaticCheck()._scan_source(source, "/fake/skill.py")
    assert "code_execution" in hits
    calls = [c[0] for c in hits["code_execution"]]
    assert "subprocess.run" in calls


def test_scan_source_clean_source_no_hits():
    source = "def greet(name: str) -> str:\n    return f'Hello, {name}'\n"
    hits = PluginStaticCheck()._scan_source(source, "/fake/skill.py")
    assert hits == {}


def test_scan_source_syntax_error_returns_empty():
    source = "def bad syntax ::::\n    pass\n"
    hits = PluginStaticCheck()._scan_source(source, "/fake/skill.py")
    assert hits == {}
```

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

```bash
.venv/bin/pytest tests/audit/checks/test_plugin_static.py -k "scan_source" -v
```

Expected: `FAILED` — `AttributeError: type object 'PluginStaticCheck' has no attribute '_scan_source'`

---

### Task 3: Tests for `_ast_scan()` integration

**Files:**
- Modify: `tests/audit/checks/test_plugin_static.py`

- [ ] **Step 1: Write the failing integration test for `_ast_scan()`**

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

```python
@pytest.mark.asyncio
async def test_ast_scan_detects_subprocess_high_finding():
    """Executor returns a skill file with subprocess.run — expect high finding."""
    find_argv = ["find", "/home/user/.openclaw/skills/", "-type", "f", "-name", "*.py"]
    cat_argv  = ["/home/user/.openclaw/skills/evil-skill.py"]

    class _Executor:
        def run(self, argv):
            if argv[:1] == ["find"]:
                return _ok(argv, "/home/user/.openclaw/skills/evil-skill.py\n")
            if argv == ["cat", "/home/user/.openclaw/skills/evil-skill.py"]:
                return _ok(argv, "import subprocess\nsubprocess.run(['rm', '-rf', '/'])\n")
            return _ok(argv, "")

    from agentsec.audit.platform_profile import PlatformProfile

    linux_with_skills = PlatformProfile(
        name="linux",
        paths={
            "openclaw_home": "~/.openclaw/",
            "memory_file": "~/.openclaw/MEMORY.md",
            "log_dir": "/var/log/openclaw/",
            "systemd_unit": "/etc/systemd/system/openclaw.service",
            "skills_dir": "/home/user/.openclaw/skills/",
        },
        services=LINUX.services,
        listening_check=LINUX.listening_check,
    )

    ctx = CheckContext(
        executor=_Executor(), redactor=Redactor(), ioc_dir=None,
        profile=linux_with_skills, status=CheckStatus(),
    )
    findings = await PluginStaticCheck()._ast_scan(ctx)
    high = [f for f in findings if f.severity == "high"]
    assert len(high) == 1
    assert high[0].evidence["category"] == "code_execution"
    assert any("subprocess.run" in c for c in high[0].evidence["matched_calls"])


@pytest.mark.asyncio
async def test_ast_scan_skips_when_no_skills_dir():
    """profile.paths has no skills_dir → _ast_scan returns [] without calling executor."""
    class _Executor:
        def run(self, argv):
            raise AssertionError("executor must not be called when skills_dir absent")

    from agentsec.audit.platform_profile import PlatformProfile

    profile_no_skills = PlatformProfile(
        name="linux",
        paths={
            "openclaw_home": "~/.openclaw/",
            "memory_file": "~/.openclaw/MEMORY.md",
            "log_dir": "/var/log/openclaw/",
            "systemd_unit": "/etc/systemd/system/openclaw.service",
        },
        services=LINUX.services,
        listening_check=LINUX.listening_check,
    )

    ctx = CheckContext(
        executor=_Executor(), redactor=Redactor(), ioc_dir=None,
        profile=profile_no_skills, status=CheckStatus(),
    )
    findings = await PluginStaticCheck()._ast_scan(ctx)
    assert findings == []
```

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

```bash
.venv/bin/pytest tests/audit/checks/test_plugin_static.py -k "scan_source or ast_scan" -v
```

Expected: all 5 new tests `FAILED`

---

### Task 4: Implement `_scan_source`, `_ast_scan`, and wire into `run()`

**Files:**
- Modify: `src/agentsec/audit/checks/plugin_static.py`

- [ ] **Step 1: Replace the file with the full implementation**

```python
"""plugin_static check (spec §3.2 #6).

Inventories installed OpenClaw skills via `openclaw skills list --json` and
matches each against `clawhavoc_skills.json`. Also scans skill Python source
files under `profile.paths["skills_dir"]` for dangerous API calls using the
evaluator-side Python ast module.

Lookup order is sha256-first, name-second (bug_007): a non-null sha is the
strong signal — name is what the gateway reports and may have been changed
by an attacker after the IOC was published. v0.2.0's seeded
`clawhavoc_skills.json` rows all carry `sha256: null` (no public sha
available at seed time), so today the sha index is effectively empty and
matches fall through to the name index. As soon as a real sha is added,
hostile renames stop evading detection.
"""

from __future__ import annotations

import json
from pathlib import Path

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

# Call-based danger patterns: matched via ast.Call nodes only.
# Keys are human-readable category names used in finding evidence.
_DANGER_CALLS: dict[str, set[tuple[str, str]]] = {
    "code_execution": {
        ("subprocess", "run"), ("subprocess", "Popen"),
        ("subprocess", "call"), ("subprocess", "check_output"),
        ("os", "system"), ("os", "popen"),
    },
    "dynamic_eval": {
        ("builtins", "eval"), ("builtins", "exec"),
    },
    "network_exfiltration": {
        ("requests", "get"), ("requests", "post"), ("requests", "request"),
        ("urllib.request", "urlopen"),
        ("httpx", "get"), ("httpx", "post"), ("httpx", "request"),
    },
    "raw_socket": {
        ("socket", "connect"), ("socket", "create_connection"),
    },
}

# Attribute-access danger patterns: matched via bare ast.Attribute nodes.
# Separate from _DANGER_CALLS to avoid double-counting (ast.walk visits both
# a Call node and the Attribute inside its func).
_DANGER_ATTRS: dict[str, set[tuple[str, str]]] = {
    "env_access": {
        ("os", "environ"),
    },
}


class PluginStaticCheck(Check):
    id = "plugin_static"
    _ARGV = ["openclaw", "skills", "list", "--json"]

    async def run(self, ctx: CheckContext) -> list[Finding]:
        findings: list[Finding] = []
        findings.extend(self._ioc_scan(ctx))
        # _ioc_scan sets ctx.status.skipped when basic prerequisites are
        # absent (no ioc_dir, no DB file). Skip AST scan too — if the check
        # environment isn't configured, AST results are not meaningful.
        if not ctx.status.is_skipped:
            findings.extend(await self._ast_scan(ctx))
        return findings

    # ------------------------------------------------------------------
    # IOC fingerprint scan (unchanged from v0.2.0)
    # ------------------------------------------------------------------

    def _ioc_scan(self, ctx: CheckContext) -> list[Finding]:
        if ctx.ioc_dir is None:
            ctx.status.skipped("plugin_static: ioc_dir not configured")
            return []
        db_path = Path(ctx.ioc_dir) / "clawhavoc_skills.json"
        if not db_path.exists():
            ctx.status.skipped(f"plugin_static: {db_path} missing")
            return []
        try:
            db = json.loads(db_path.read_text())
        except json.JSONDecodeError as e:
            ctx.status.skipped(f"plugin_static: malformed clawhavoc DB: {e}")
            return []
        ioc_by_name = {entry.get("name"): entry for entry in db if entry.get("name")}
        ioc_by_sha = {entry["sha256"]: entry for entry in db if entry.get("sha256")}

        res = ctx.executor.run(self._ARGV)
        if res.rejected:
            ctx.status.skipped(f"plugin_static: {res.reject_reason}")
            return []
        if res.exit_code != 0:
            ctx.status.skipped(f"plugin_static: skills list exit {res.exit_code}")
            return []
        try:
            payload = json.loads(res.stdout)
        except json.JSONDecodeError as e:
            ctx.status.skipped(f"plugin_static: skills JSON invalid: {e}")
            return []

        findings: list[Finding] = []
        for skill in payload.get("skills", []):
            name = skill.get("name", "")
            sha = skill.get("hash") or skill.get("sha256")
            ioc = (ioc_by_sha.get(sha) if sha else None) or ioc_by_name.get(name)
            if ioc is None:
                continue
            findings.append(self.make_finding(
                ctx,
                severity="critical",
                title=f"Installed skill matches ClawHavoc IOC: {name}",
                description=ioc.get("claim", ""),
                evidence={
                    "matched_name": name,
                    "installed_version": skill.get("version"),
                    "installed_hash": sha,
                    "ioc_published_after": ioc.get("published_after"),
                },
                remediation=(
                    f"Uninstall skill {name} and rotate any credentials"
                    " it could have accessed."
                ),
                threat_refs=list(ioc.get("threat_refs", [])),
            ))
        return findings

    # ------------------------------------------------------------------
    # AST danger-pattern scan (new in v0.5.2)
    # ------------------------------------------------------------------

    async def _ast_scan(self, ctx: CheckContext) -> list[Finding]:
        """Scan skill Python files for dangerous API calls via ast analysis."""
        skills_dir = ctx.profile.paths.get("skills_dir")
        if not skills_dir:
            return []

        find_argv = ["find", skills_dir, "-type", "f", "-name", "*.py"]
        res = ctx.executor.run(find_argv)
        if res.rejected or res.exit_code != 0 or not res.stdout.strip():
            return []

        findings: list[Finding] = []
        for skill_path in res.stdout.splitlines():
            skill_path = skill_path.strip()
            if not skill_path:
                continue
            cat_res = ctx.executor.run(["cat", skill_path])
            if cat_res.rejected or cat_res.exit_code != 0:
                continue
            hits = self._scan_source(cat_res.stdout, skill_path)
            skill_name = skill_path.rsplit("/", 1)[-1]
            for category, matches in hits.items():
                findings.append(self.make_finding(
                    ctx,
                    severity="high",
                    title=f"Dangerous API in skill {skill_name}: {category}",
                    description=(
                        f"Skill file {skill_path} uses {category} API "
                        f"(first match at line {matches[0][1]})."
                    ),
                    evidence={
                        "skill_path": skill_path,
                        "category": category,
                        "matched_calls": [f"{m[0]}:{m[1]}" for m in matches],
                    },
                    remediation=(
                        f"Review {skill_path} for {category} usage. "
                        "Remove or justify each occurrence before deployment."
                    ),
                    threat_refs=["TI-CLAWHAVOC-OVERVIEW"],
                ))
        return findings

    def _scan_source(
        self, source: str, skill_path: str
    ) -> dict[str, list[tuple[str, int]]]:
        """Return {category: [(call_repr, lineno), ...]} for danger matches.

        Uses two separate pattern dicts to avoid double-counting: ast.walk
        visits both a Call node and the Attribute inside its func, so call
        patterns are matched only in the Call handler and attribute-access
        patterns only in the Attribute handler.
        """
        import ast as _ast

        try:
            tree = _ast.parse(source)
        except SyntaxError:
            return {}

        hits: dict[str, list[tuple[str, int]]] = {}
        for node in _ast.walk(tree):
            if isinstance(node, _ast.Call):
                func = node.func
                if isinstance(func, _ast.Attribute) and isinstance(func.value, _ast.Name):
                    key = (func.value.id, func.attr)
                    for cat, patterns in _DANGER_CALLS.items():
                        if key in patterns:
                            hits.setdefault(cat, []).append((
                                f"{func.value.id}.{func.attr}",
                                getattr(node, "lineno", 0),
                            ))
                elif isinstance(func, _ast.Name):
                    key = ("builtins", func.id)
                    for cat, patterns in _DANGER_CALLS.items():
                        if key in patterns:
                            hits.setdefault(cat, []).append((
                                func.id,
                                getattr(node, "lineno", 0),
                            ))
            elif (
                isinstance(node, _ast.Attribute)
                and isinstance(node.value, _ast.Name)
            ):
                key = (node.value.id, node.attr)
                for cat, patterns in _DANGER_ATTRS.items():
                    if key in patterns:
                        hits.setdefault(cat, []).append((
                            f"{node.value.id}.{node.attr}",
                            getattr(node, "lineno", 0),
                        ))
        return hits
```

- [ ] **Step 2: Run all 5 new tests — expect PASS**

```bash
.venv/bin/pytest tests/audit/checks/test_plugin_static.py -k "scan_source or ast_scan" -v
```

Expected:
```
PASSED test_scan_source_detects_subprocess_run
PASSED test_scan_source_clean_source_no_hits
PASSED test_scan_source_syntax_error_returns_empty
PASSED test_ast_scan_detects_subprocess_high_finding
PASSED test_ast_scan_skips_when_no_skills_dir
```

- [ ] **Step 3: Run the full test file — no regressions**

```bash
.venv/bin/pytest tests/audit/checks/test_plugin_static.py -v
```

Expected: all tests pass (4 original + 1 profile + 5 new = 10 total)

- [ ] **Step 4: Commit**

```bash
git add src/agentsec/audit/checks/plugin_static.py tests/audit/checks/test_plugin_static.py
git commit -m "feat(audit): add AST danger-pattern scan to plugin_static

Scans skill Python files under profile.paths[skills_dir] for five
danger categories (code_execution, dynamic_eval, network_exfiltration,
raw_socket, env_access) via Python ast module on the evaluator.
One high-severity finding per (skill_file, category). IOC fingerprint
matching refactored into _ioc_scan(); both run from run().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

### Task 5: Full test suite regression + ruff lint

**Files:** (read-only run)

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

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

Expected: all tests pass. If any unrelated test fails, investigate before proceeding.

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

```bash
.venv/bin/ruff check src/ --fix
```

Expected: no errors. If auto-fixed, commit:

```bash
git add -p src/ && git commit -m "chore: ruff auto-fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```

---

### Task 6: Update ROADMAP and CHANGELOG

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

- [ ] **Step 1: Mark ROADMAP item done**

In `ROADMAP.md`, find:
```
- [ ] Plugin AST-grep danger-pattern 扫描（`plugin_static` 扩展）
```
Replace with:
```
- [x] Plugin AST-grep danger-pattern 扫描（`plugin_static` 扩展）— v0.5.2，2026-05-04
```

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

In `CHANGELOG.md`, append to the existing `## [Unreleased]` → `### Added` list:

```
- `PluginStaticCheck` now scans installed skill Python source files for
  dangerous API calls via evaluator-side `ast` analysis. Five categories:
  `code_execution` (subprocess/os), `dynamic_eval` (eval/exec),
  `network_exfiltration` (requests/httpx/urllib), `raw_socket` (socket),
  `env_access` (os.environ). One `high`-severity finding per
  `(skill_file, category)`. IOC fingerprint matching (`critical`) is
  unaffected. Requires `skills_dir` in `PlatformProfile.paths`.
- `PlatformProfile`: added `skills_dir` key to `LINUX`
  (`~/.openclaw/skills/`) and `MACOS`
  (`~/Library/Application Support/openclaw/skills/`).
```

- [ ] **Step 3: Commit**

```bash
git add ROADMAP.md CHANGELOG.md
git commit -m "chore: mark plugin AST scan complete in ROADMAP; update CHANGELOG

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
```
