# Bot-Detection Cookie Filter Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Filter known WAF bot-detection cookies when injecting session cookies into nodriver browsers, preventing Akamai's `ERR_HTTP2_PROTOCOL_ERROR` cascade in observe mode.

**Architecture:** Add a `BOT_DETECTION_COOKIE_PREFIXES` constant in `session.py` with confirmed Akamai patterns (plus commented-out patterns for other WAFs). Modify `inject_cookies_to_nodriver()` to skip matching cookies by default via a `skip_bot_cookies` parameter. Log skipped cookies at debug level and the total skip count at info level.

**Tech Stack:** Python, pytest, nodriver CDP (existing)

---

### Task 1: Add bot-detection cookie constant and filtering logic

**Files:**
- Modify: `src/graftpunk/session.py:674-714`
- Test: `tests/unit/test_session.py`

**Step 1: Write failing tests**

Add a new test class `TestBotCookieFiltering` in `tests/unit/test_session.py` after the existing `TestInjectCookiesToNodriver` class (around line 1759). Add these tests:

```python
class TestBotCookieFiltering:
    """Tests for bot-detection cookie filtering in inject_cookies_to_nodriver."""

    @pytest.mark.asyncio
    async def test_skips_akamai_cookies_by_default(self) -> None:
        """Akamai bot-detection cookies are filtered out by default."""
        from unittest.mock import AsyncMock

        from requests.cookies import RequestsCookieJar

        from graftpunk.session import inject_cookies_to_nodriver

        jar = RequestsCookieJar()
        jar.set("session_id", "abc123", domain=".example.com", path="/")
        jar.set("bm_sv", "akamai_track", domain=".example.com", path="/")
        jar.set("ak_bmsc", "akamai_session", domain=".example.com", path="/")
        jar.set("_abck", "akamai_bot", domain=".example.com", path="/")
        jar.set("csrf", "xyz", domain=".example.com", path="/")

        mock_tab = AsyncMock()
        result = await inject_cookies_to_nodriver(mock_tab, jar)

        # Only session_id and csrf should be injected (3 Akamai cookies skipped)
        assert result == 2
        mock_tab.send.assert_called_once()

    @pytest.mark.asyncio
    async def test_skip_bot_cookies_false_injects_all(self) -> None:
        """Setting skip_bot_cookies=False injects everything including bot cookies."""
        from unittest.mock import AsyncMock

        from requests.cookies import RequestsCookieJar

        from graftpunk.session import inject_cookies_to_nodriver

        jar = RequestsCookieJar()
        jar.set("session_id", "abc123", domain=".example.com", path="/")
        jar.set("bm_sv", "akamai_track", domain=".example.com", path="/")
        jar.set("_abck", "akamai_bot", domain=".example.com", path="/")

        mock_tab = AsyncMock()
        result = await inject_cookies_to_nodriver(mock_tab, jar, skip_bot_cookies=False)

        assert result == 3
        mock_tab.send.assert_called_once()

    @pytest.mark.asyncio
    async def test_bm_prefix_matches_all_variants(self) -> None:
        """The bm_ prefix matches bm_sv, bm_so, bm_s, bm_sz, etc."""
        from unittest.mock import AsyncMock

        from requests.cookies import RequestsCookieJar

        from graftpunk.session import inject_cookies_to_nodriver

        jar = RequestsCookieJar()
        jar.set("bm_sv", "val1", domain=".example.com", path="/")
        jar.set("bm_so", "val2", domain=".example.com", path="/")
        jar.set("bm_s", "val3", domain=".example.com", path="/")
        jar.set("bm_sz", "val4", domain=".example.com", path="/")

        mock_tab = AsyncMock()
        result = await inject_cookies_to_nodriver(mock_tab, jar)

        assert result == 0
        mock_tab.send.assert_not_called()

    @pytest.mark.asyncio
    async def test_non_bot_cookies_with_similar_names_not_filtered(self) -> None:
        """Cookies that don't match bot prefixes are not filtered."""
        from unittest.mock import AsyncMock

        from requests.cookies import RequestsCookieJar

        from graftpunk.session import inject_cookies_to_nodriver

        jar = RequestsCookieJar()
        jar.set("bookmark", "page5", domain=".example.com", path="/")
        jar.set("abcdef", "val", domain=".example.com", path="/")

        mock_tab = AsyncMock()
        result = await inject_cookies_to_nodriver(mock_tab, jar)

        # "bookmark" does NOT start with "bm_", "abcdef" does NOT start with "_abck"
        assert result == 2

    @pytest.mark.asyncio
    async def test_constant_is_importable(self) -> None:
        """BOT_DETECTION_COOKIE_PREFIXES is a public constant."""
        from graftpunk.session import BOT_DETECTION_COOKIE_PREFIXES

        assert isinstance(BOT_DETECTION_COOKIE_PREFIXES, tuple)
        assert len(BOT_DETECTION_COOKIE_PREFIXES) > 0
        # Confirmed Akamai patterns present
        assert "bm_" in BOT_DETECTION_COOKIE_PREFIXES
        assert "ak_bmsc" in BOT_DETECTION_COOKIE_PREFIXES
        assert "_abck" in BOT_DETECTION_COOKIE_PREFIXES
```

**Step 2: Run tests to verify they fail**

Run: `uv run pytest tests/unit/test_session.py::TestBotCookieFiltering -v`
Expected: FAIL — `BOT_DETECTION_COOKIE_PREFIXES` not found, `skip_bot_cookies` parameter not accepted

**Step 3: Implement the constant and filtering logic**

In `src/graftpunk/session.py`, add the constant just above the `inject_cookies_to_nodriver` function (before line 674):

```python
# Known bot-detection / WAF tracking cookies that should NOT be injected
# into fresh browser sessions. These cookies carry stale bot-classification
# state from previous sessions — injecting them causes WAFs to immediately
# flag the browser, resulting in connection-level rejection (e.g., HTTP/2
# RST_STREAM / ERR_HTTP2_PROTOCOL_ERROR).
# See: https://github.com/stavxyz/graftpunk/issues/46
BOT_DETECTION_COOKIE_PREFIXES: tuple[str, ...] = (
    # Akamai Bot Manager — confirmed to cause ERR_HTTP2_PROTOCOL_ERROR
    # when stale cookies carry a "flagged" bot classification into a new
    # browser session.
    "bm_",
    "ak_bmsc",
    "_abck",
    # Uncomment as needed when plugins encounter these WAFs:
    # Cloudflare Bot Management
    # "__cf_bm",
    # "cf_clearance",
    # Imperva / Incapsula
    # "visid_incap",
    # "incap_ses_",
    # PerimeterX (now HUMAN Security)
    # "_px",
    # DataDome
    # "datadome",
)
```

Then modify `inject_cookies_to_nodriver` to accept and use the filter:

```python
async def inject_cookies_to_nodriver(
    tab: Any,
    cookies: "requests.cookies.RequestsCookieJar",
    *,
    skip_bot_cookies: bool = True,
) -> int:
    """Inject cached session cookies into a nodriver browser tab via CDP.

    This is the inverse of transfer_nodriver_cookies_to_session() — it loads
    cookies FROM a cached RequestsCookieJar INTO a nodriver browser.

    Can be called before any navigation. CookieParam includes the domain
    field, so CDP can set cookies on any domain without needing to be on
    that domain first.

    Bot-detection cookies (Akamai bm_*, ak_bmsc, _abck) are skipped by
    default to prevent WAFs from flagging the browser based on stale
    classification state from a previous session.

    Args:
        tab: nodriver Tab instance (the active browser tab).
        cookies: RequestsCookieJar from a cached session.
        skip_bot_cookies: If True (default), skip cookies matching
            BOT_DETECTION_COOKIE_PREFIXES.

    Returns:
        Number of cookies actually injected.
    """
    import nodriver.cdp.network as cdp_net
    import nodriver.cdp.storage as cdp_storage

    cookie_params = []
    skipped = 0
    for cookie in cookies:
        if cookie.value is None:
            continue
        if skip_bot_cookies and any(
            cookie.name.startswith(prefix)
            for prefix in BOT_DETECTION_COOKIE_PREFIXES
        ):
            LOG.debug(
                "inject_skipping_bot_cookie",
                name=cookie.name,
                domain=cookie.domain,
            )
            skipped += 1
            continue
        cookie_params.append(
            cdp_net.CookieParam(  # type: ignore[attr-defined]
                name=cookie.name,
                value=cookie.value,
                domain=cookie.domain,
                path=cookie.path,
            )
        )
    if skipped:
        LOG.info("inject_bot_cookies_skipped", count=skipped)
    if cookie_params:
        await tab.send(cdp_storage.set_cookies(cookie_params))
    return len(cookie_params)
```

**Step 4: Run tests to verify they pass**

Run: `uv run pytest tests/unit/test_session.py::TestBotCookieFiltering -v`
Expected: All 5 tests PASS

**Step 5: Run full test suite + quality checks**

Run: `uv run pytest tests/ -v && uvx ruff check . && uvx ruff format --check . && uvx ty check src/`
Expected: All 1376+ tests PASS, no lint/format/type errors

**Step 6: Commit**

```bash
git add src/graftpunk/session.py tests/unit/test_session.py
git commit -m "feat: filter bot-detection cookies when injecting into nodriver browsers

Skip Akamai tracking cookies (bm_*, ak_bmsc, _abck) by default when
injecting session cookies into nodriver browsers. These cookies carry
stale bot-classification state that causes WAFs to reject the browser
with ERR_HTTP2_PROTOCOL_ERROR.

Closes #46

Co-Authored-By: stavxyz <hi@stav.xyz>"
```

---

### Task 2: Update documentation

**Files:**
- Modify: `CHANGELOG.md`
- Modify: `docs/HOW_IT_WORKS.md`

**Step 1: Update CHANGELOG.md**

Add under `## [Unreleased]` → `### Added`, after the "Eager CDP Body Fetching" entry:

```markdown
- **Bot-Detection Cookie Filtering**: Skip WAF tracking cookies when injecting into browsers
  - Akamai cookies (`bm_*`, `ak_bmsc`, `_abck`) filtered by default in `inject_cookies_to_nodriver()`
  - Prevents `ERR_HTTP2_PROTOCOL_ERROR` caused by stale bot-classification state
  - Opt-out via `skip_bot_cookies=False` parameter
  - Commented-out patterns for Cloudflare, Imperva, PerimeterX, DataDome (ready to enable)
```

**Step 2: Update docs/HOW_IT_WORKS.md**

In the "Session Management" → "Loading" section (or nearby), add a subsection about cookie filtering. Find the text that discusses `inject_cookies_to_nodriver` or cookie injection into nodriver browsers, and add:

```markdown
### Bot-Detection Cookie Filtering

When injecting cookies into a nodriver browser (for observe mode, token extraction, etc.), graftpunk automatically skips known WAF bot-detection cookies. These cookies — set by services like Akamai Bot Manager — carry bot-classification state from previous sessions. Injecting stale copies causes WAFs to immediately flag the new browser, resulting in connection-level rejection (`ERR_HTTP2_PROTOCOL_ERROR`).

Filtered by default:
- `bm_*`, `ak_bmsc`, `_abck` (Akamai Bot Manager)

The filter can be disabled per-call with `skip_bot_cookies=False`. Additional WAF patterns (Cloudflare, Imperva, PerimeterX, DataDome) are included as commented-out entries in `BOT_DETECTION_COOKIE_PREFIXES` for easy activation.
```

**Step 3: Commit**

```bash
git add CHANGELOG.md docs/HOW_IT_WORKS.md
git commit -m "docs: document bot-detection cookie filtering

Co-Authored-By: stavxyz <hi@stav.xyz>"
```

---

### Verification

After both tasks, run the full verification:

```bash
uv run pytest tests/ -v
uvx ruff check .
uvx ruff format --check .
uvx ty check src/
```
