diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..1a79210
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,35 @@
+name: CI
+
+on:
+ push:
+ branches: [main, master]
+ pull_request:
+ branches: [main, master]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: PHP 8.4 / Test & Analyse
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ extensions: curl, mbstring, xml
+ coverage: xdebug
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress --no-interaction
+
+ - name: Run tests with coverage
+ run: vendor/bin/pest --coverage --min=80
+
+ - name: Run PHPStan
+ run: vendor/bin/phpstan analyse --no-progress
diff --git a/.omc/state/checkpoints/checkpoint-2026-03-10T08-14-41-766Z.json b/.omc/state/checkpoints/checkpoint-2026-03-10T08-14-41-766Z.json
new file mode 100644
index 0000000..acc51c1
--- /dev/null
+++ b/.omc/state/checkpoints/checkpoint-2026-03-10T08-14-41-766Z.json
@@ -0,0 +1,11 @@
+{
+ "created_at": "2026-03-10T08:14:41.765Z",
+ "trigger": "auto",
+ "active_modes": {},
+ "todo_summary": {
+ "pending": 0,
+ "in_progress": 0,
+ "completed": 0
+ },
+ "wisdom_exported": false
+}
\ No newline at end of file
diff --git a/.omc/state/subagent-tracking.json b/.omc/state/subagent-tracking.json
new file mode 100644
index 0000000..f6b7431
--- /dev/null
+++ b/.omc/state/subagent-tracking.json
@@ -0,0 +1,26 @@
+{
+ "agents": [
+ {
+ "agent_id": "a699400d97bee152d",
+ "agent_type": "Explore",
+ "started_at": "2026-03-10T08:10:14.616Z",
+ "parent_mode": "none",
+ "status": "failed",
+ "completed_at": "2026-03-10T08:11:31.007Z",
+ "duration_ms": 76391
+ },
+ {
+ "agent_id": "a308486ee44f9c951",
+ "agent_type": "Explore",
+ "started_at": "2026-03-10T08:12:45.759Z",
+ "parent_mode": "none",
+ "status": "failed",
+ "completed_at": "2026-03-10T08:14:41.642Z",
+ "duration_ms": 115883
+ }
+ ],
+ "total_spawned": 2,
+ "total_completed": 0,
+ "total_failed": 2,
+ "last_updated": "2026-03-10T08:16:10.392Z"
+}
\ No newline at end of file
diff --git a/BACKLOG.md b/BACKLOG.md
new file mode 100644
index 0000000..d6d65cb
--- /dev/null
+++ b/BACKLOG.md
@@ -0,0 +1,177 @@
+# plugin_webseer — Backlog
+
+---
+
+### Issue #1: security(sql): parameterise email address in plugin_webseer_update_contacts()
+- **Priority**: high
+- **Labels**: [security, sql-injection, hardening]
+- **Branch**: `security/1-parameterise-contacts-email`
+- **Evidence**: `includes/functions.php:319-325` — `db_execute("REPLACE INTO plugin_webseer_contacts ... '" . $u['email_address'] . "'")`
+- **Acceptance criteria**:
+ - [ ] Both `db_execute()` calls replaced with `db_execute_prepared()` using `?` placeholders
+ - [ ] `$u['id']`, `$cid`, and `$u['email_address']` all bound as parameters
+ - [ ] Unit test covers email address containing single quote
+ - [ ] PHPStan level 6 passes
+- **Dependencies**: none
+
+---
+
+### Issue #2: security(sql): parameterise rfilter RLIKE clause in list_urls()
+- **Priority**: high
+- **Labels**: [security, sql-injection, hardening]
+- **Branch**: `security/2-parameterise-rfilter-rlike`
+- **Evidence**: `webseer.php:744-749` — `'display_name RLIKE \'' . get_request_var('rfilter') . '\''`
+- **Acceptance criteria**:
+ - [ ] All five RLIKE comparisons use `db_fetch_assoc_prepared()` with bound `?` params
+ - [ ] Or `db_qstr()` wrapping applied as interim measure
+ - [ ] Regression test with a regex containing a single quote
+ - [ ] PHPStan passes
+- **Dependencies**: none
+
+---
+
+### Issue #3: security(sql): parameterise proxy filter LIKE clause in proxies()
+- **Priority**: high
+- **Labels**: [security, sql-injection, hardening]
+- **Branch**: `security/3-parameterise-proxy-filter`
+- **Evidence**: `webseer_proxies.php:255` — `' name LIKE "%' . get_request_var('filter') . '%" OR hostname LIKE ...'`
+- **Acceptance criteria**:
+ - [ ] Filter value bound via prepared statement parameter
+ - [ ] Test covers filter value with `%`, `_`, `'` characters
+- **Dependencies**: none
+
+---
+
+### Issue #4: security(sql): replace manual strip with prepared statement in remote.php SETMASTER
+- **Priority**: high
+- **Labels**: [security, sql-injection, hardening]
+- **Branch**: `security/4-setmaster-prepared`
+- **Evidence**: `remote.php:174-176` — `str_replace(array("'", '\\'), '', $_POST['ip'])` then `db_fetch_row("... WHERE ip = '$ip'")`
+- **Acceptance criteria**:
+ - [ ] `db_fetch_row_prepared('... WHERE ip = ?', [$ip])` replaces the concatenated query
+ - [ ] Manual `str_replace` sanitisation removed
+ - [ ] Test covers IP containing SQL metacharacters
+- **Dependencies**: none
+
+---
+
+### Issue #5: security(ssrf): implement UrlValidator and wire into cURL class
+- **Priority**: high
+- **Labels**: [security, ssrf, hardening]
+- **Branch**: `security/5-ssrf-url-validator`
+- **Evidence**: `classes/cURL.php:91,136` — `curl_init($url)` without host validation
+- **Acceptance criteria**:
+ - [ ] `src/Security/UrlValidator.php` validates scheme allowlist (http/https only)
+ - [ ] Blocks RFC-1918, loopback, link-local, AWS metadata ranges
+ - [ ] `cURL::get()` calls `UrlValidator::isAllowed()` before `curl_init()`; returns error result on rejection
+ - [ ] `cURL::post()` calls `UrlValidator::isAllowed()` before `curl_init()`
+ - [ ] All `->todo()` annotations in `SsrfTest.php` converted to passing tests
+ - [ ] PHPStan passes
+- **Dependencies**: `src/Security/UrlValidator.php` skeleton already present
+
+---
+
+### Issue #6: security(deserialization): replace unserialize with JSON in server sync
+- **Priority**: high
+- **Labels**: [security, deserialization, hardening]
+- **Branch**: `security/6-replace-unserialize-json`
+- **Evidence**: `includes/functions.php:75,107` — `unserialize(base64_decode($servers))` / `unserialize(base64_decode($urls))`
+- **Acceptance criteria**:
+ - [ ] `plugin_webseer_refresh_servers()` and `plugin_webseer_refresh_urls()` decode JSON instead of PHP serialised data
+ - [ ] `remote.php` GETSERVERS/GETURLS actions encode with `json_encode()` instead of `serialize()`
+ - [ ] If serialised format cannot be changed immediately, at minimum add `['allowed_classes' => false]` to both `unserialize()` calls
+ - [ ] Test covers malicious serialised object payload being rejected
+- **Dependencies**: #11 (remote.php hardening) for protocol change
+
+---
+
+### Issue #7: security(xss): escape URL in history and list view anchor tags
+- **Priority**: high
+- **Labels**: [security, xss, hardening]
+- **Branch**: `security/7-escape-url-anchor-tags`
+- **Evidence**: `webseer.php:651`, `webseer_servers.php:434` — `href='" . $row['url'] . "'"` without `html_escape()`
+- **Acceptance criteria**:
+ - [ ] Both `$row['url']` occurrences wrapped with `html_escape()`
+ - [ ] Scheme validated to be `http` or `https` before rendering as a clickable link; otherwise render as plain text
+ - [ ] Test covers URL with `javascript:` scheme and with `';
+ $escaped = htmlspecialchars($storedUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+ expect($escaped)->not->toContain('