Stage 7: ad/tracker blocking via Brave's adblock-rust (C FFI)

ober

1e05cbf099522f59b4ffd928faf9a8e671722faf

diff --git a/.build.yml b/.build.yml
index 19dc713..b4ae7e3 100644
--- a/.build.yml
+++ b/.build.yml
@@ -14,6 +14,11 @@ packages:
   - uuid-dev
   - liblz4-dev
   - zlib1g-dev
+  # Rust toolchain for the vendored adblock-rust-ffi staticlib (cmake runs
+  # `cargo build --release`, linked into libjerboa_browser). Cargo.lock pins the
+  # deps; if bookworm's rustc is ever too old, switch this to a rustup install.
+  - cargo
+  - rustc
 sources:
   - https://git.sr.ht/~lisp/jerboa-browser
   # Chez Scheme is vendored in the jerboa repo (vendor/ChezScheme); cloned so CI
@@ -60,6 +65,11 @@ tasks:
       echo "--- native binary ---"
       tar xzf "$tb" -O jerboa-browser-0.0.1-linux-x86_64/bin/jerboa-browser \
         > /tmp/jb && file /tmp/jb
+      # Stage 7 — adblock-rust engine_match semantics (pure C++, no Qt/network).
+      # build-tarball.sh only builds the jerboa_browser target, so build the test.
+      echo "--- adblock ctest ---"
+      cmake --build qt-webengine/build --target jwb_adblock_test
+      ctest --test-dir qt-webengine/build -R adblock --output-on-failure
   - test-gui: |
       cd jerboa-browser
       # Offline Qt GUI / snapshot harness (offscreen, headless). Kept hermetic
@@ -162,3 +172,15 @@ tasks:
       make test-polish
       # Stage all harness snapshots (stage0 + stage1 + stage2) as an artifact.
       tar czf gui-snapshots.tar.gz test-artifacts
+  - test-adblock: |
+      cd jerboa-browser
+      # Stage 7 ad/tracker blocking: the process-global toggle ABI + the
+      # toggle-adblock command (C-c b) + echo (offscreen, hermetic). Opening a
+      # session also builds the engine from the built-in starter list. The
+      # engine_match semantics are covered by the C++ ctest in the verify task.
+      export QT_QPA_PLATFORM=offscreen JWB_TEST_NO_NETWORK=1
+      export QTWEBENGINE_DISABLE_SANDBOX=1
+      export QTWEBENGINE_CHROMIUM_FLAGS="--no-sandbox --disable-gpu"
+      export JERBOA_HOME="$HOME/jerboa" SCHEME="$(command -v scheme)"
+      export JERBOA_BROWSER_LIB="$PWD/qt-webengine/build/libjerboa_browser.so"
+      make test-adblock
diff --git a/Makefile b/Makefile
index 145f374..ee37649 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@ JERBOA_HOME ?= $(realpath $(CURDIR)/../jerboa)
 SCHEME      ?= $(JERBOA_HOME)/.chez/bin/scheme
 LIBDIRS     := $(CURDIR)/scheme:$(JERBOA_HOME)/lib
 
-.PHONY: binary test test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-gui test-buffers repl clean help
+.PHONY: binary test test-keymap test-minibuffer test-commands test-keys test-nav test-hint test-pass test-polish test-adblock test-gui test-buffers repl clean help
 .DEFAULT_GOAL := help
 
 # Build the self-contained native ./jerboa-browser (Chez + boot + (browser)).
@@ -57,6 +57,13 @@ test-polish:
 	QT_QPA_PLATFORM=offscreen JERBOA_HOME=$(JERBOA_HOME) \
 	  $(SCHEME) -q --libdirs $(LIBDIRS) --script scheme/browser-polish-test.ss
 
+# Stage 7 ad/tracker blocking (adblock-rust): the process-global toggle ABI and
+# the toggle-adblock command + echo. The engine_match semantics are unit-tested
+# in C++ (cd qt-webengine/build && ctest -R adblock).
+test-adblock:
+	QT_QPA_PLATFORM=offscreen JERBOA_HOME=$(JERBOA_HOME) \
+	  $(SCHEME) -q --libdirs $(LIBDIRS) --script scheme/browser-adblock-test.ss
+
 # Offline Qt GUI / snapshot harness (headless). Writes PNGs to ./test-artifacts.
 test-gui:
 	QT_QPA_PLATFORM=offscreen JERBOA_HOME=$(JERBOA_HOME) \
diff --git a/README.md b/README.md
index 11607d8..e630dae 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,7 @@ hint-mode. `C-h b` opens a buffer listing every binding.
 | `f` / `M-g` (or `; f`) | follow hint (this / new buffer) | hint-mode |
 | `C-c p s` / `C-c p f` / `C-c p k` | save / fill / forget login (RAM only) | passwords |
 | `C-x r m` / `C-x r b` | bookmark current / open bookmark | bookmarks |
+| `C-c b` | toggle ad / tracker blocking | blocking |
 | `M-x` | run command by name (shows docs) | palette |
 | `C-h b` | describe all bindings | help |
 | `C-x C-c` | quit | |
@@ -49,11 +50,16 @@ Passwords are held **in RAM only** — never written to env, config, or disk —
 XOR-masked in the heap with a per-session pad and zeroed immediately after each
 use. Bookmarks are likewise in-RAM (opt-in encrypted persistence is a later item).
 
+Ad / tracker blocking is **on by default**, powered by Brave's `adblock-rust`
+engine (vendored, linked into `libjerboa_browser`) and consulted on every
+network request. `C-c b` toggles it; the status line shows `block` while active
+and the echo area reports the running count of blocked requests.
+
 ## Build / test
 
 ```sh
 make binary        # the self-contained native ./jerboa-browser
-make test          # (browser) unit suite; also test-{keymap,minibuffer,…,polish}
+make test          # (browser) unit suite; also test-{keymap,…,polish,adblock}
 make test-gui      # offline Qt snapshot harness (offscreen, headless)
 ```
 
diff --git a/ROADMAP.md b/ROADMAP.md
index 43f9e3e..ded6451 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -248,6 +248,42 @@ and add bookmarks + help + docs.
   lists keys + commands; the new chords resolve. Needs `--disable-gpu` (the
   bindings page would otherwise FATAL the offscreen GPU surface).
 
+### Stage 7 — Ad / tracker blocking (adblock-rust) ✅ DONE
+Real, toggleable ad/tracker blocking with Brave-grade filter parsing — chosen
+over a hosts-file approximation for true filter-syntax (ABP/EasyList) parity.
+Added after the Stage 0→6 nyxt mandate; same in-process, no-disk posture.
+- **Vendored** `qt-webengine/vendor/adblock-rust-ffi` (crate `adblock-ffi`,
+  `Cargo.lock`-pinned `adblock` 0.3.15). The `JWB_ADBLOCK` cmake option (default
+  ON) runs `cargo build --release` → `libadblock.a`, linked into
+  `libjerboa_browser`; macOS links just the `.a`, Linux adds `-lpthread -ldl`.
+  `lib.h` is plain C, so C++ `#include`s it inside `extern "C"`.
+- **C++** (`browser_policy.cpp`): a process-global engine + `std::atomic` toggle
+  + blocked counter. The engine is a lazily-built singleton (`set_domain_resolver`
+  once, then `engine_create`) seeded from a built-in ~30-rule `||host^` starter
+  list. `NetworkInterceptor::interceptRequest` consults it FIRST — reordered
+  ahead of the capability/network-cap gate — and blocks iff
+  `did_match_rule && !did_match_exception`. `install_policy` builds it eagerly.
+- **ABI** (`jerboa_browser.h`): `jwb_adblock_{set_enabled,is_enabled,
+  block_count,available}`, `#ifdef JWB_ADBLOCK`-guarded; graceful no-ops (and
+  `available`→0) when built without it.
+- **Scheme**: `(browser)` adds `browser-adblock-{available?,enabled?,set!,
+  block-count}`; `(browser commands)` adds the `toggle-adblock` command (`C-c b`),
+  appends `block` to the status modes string while active, and echoes
+  "Ad blocking on (N blocked)" / "off".
+- **Tests**: hermetic C++ `ctest -R adblock` (`tests/adblock_test.cc`, 6
+  match-semantics checks: ad host blocked, safe host allowed, tracker blocked,
+  `@@` exception allows, real `doubleclick.net` blocked, first-party page
+  allowed — no Qt, no network) + `make test-adblock` (`browser-adblock-test.ss`,
+  7 cases: toggle ABI + return value, block-count non-neg, session-open builds
+  the engine, command registered, `C-c b` flips + echoes, run-by-name toggles).
+  CI builds the staticlib in `build` and runs both in `verify` + `test-adblock`.
+- **Deferred follow-ups**: load a full EasyList/EasyPrivacy file at startup (the
+  starter list is intentionally tiny to keep the repo hermetic); cosmetic
+  (element-hiding) filtering via the engine's cosmetic API; a PSL-aware domain
+  resolver (the current naive one treats the whole host as the registrable
+  domain — fine for hostname-anchored rules); hide the internal Rust `adblock::*`
+  symbols the staticlib re-exports.
+
 ---
 
 ## Testing doctrine (mirrors jerboa-emacs)
diff --git a/WISHLIST.md b/WISHLIST.md
index fa59ae0..835cb59 100644
--- a/WISHLIST.md
+++ b/WISHLIST.md
@@ -14,6 +14,9 @@ offline Qt snapshot + functional tests modeled on `~/mine/jerboa-emacs`.
 - [x] **Hint-mode** — link hinting / follow (`f`, `M-g`, `; f`) via JS overlay; home-row labels, follow this/new buffer, C-g/escape cancels — *Stage 4* — `make test-hint` (10)
 - [x] **Password manager** — in-process, memory-only (no env, no config/file), masked entry, autofill; secrets XOR-masked in RAM + zeroed after use — *Stage 5* — `(browser passwords)`; `C-c p s/f/k`; `make test-pass` (12)
 - [x] **Polish** — status modes (`[emacs hint] [i/n] title — url`), echo-area wiring (minibuffer line = echo area), M-x palette shows docs, in-RAM bookmarks (`C-x r m/b`), `describe-bindings` (`C-h b`) help page, README keymap — *Stage 6* — `(browser bookmarks)`; `make test-polish` (14)
+- [x] **Ad / tracker blocking** — Brave's `adblock-rust` engine via its C FFI (vendored, linked into `libjerboa_browser`); a process-global engine + toggle consulted first in the network interceptor (block iff matched & not excepted); `C-c b` toggle, `block` status mode, blocked-count echo — *Stage 7* — `(browser)` adblock wrappers + `toggle-adblock`; `make test-adblock` (7) + C++ `ctest -R adblock` (6)
 
 Later / maybe: vi keyscheme parity, AppImage packaging, history persistence
-(opt-in, encrypted), search-engine prefixes, download manager, ad/JS toggles.
+(opt-in, encrypted), search-engine prefixes, download manager, JS toggles;
+ad-blocking follow-ups — full EasyList/EasyPrivacy loading, cosmetic
+(element-hiding) filtering, a PSL-aware domain resolver.
diff --git a/include/jerboa_browser.h b/include/jerboa_browser.h
index 5b975e0..801e7c6 100644
--- a/include/jerboa_browser.h
+++ b/include/jerboa_browser.h
@@ -244,6 +244,18 @@ JWB_API const char *jwb_window_poll_key(JwbHandle window);
  * Scheme run-loop exit when the window is dismissed. */
 JWB_API int jwb_window_is_visible(JwbHandle window);
 
+/* === Ad / tracker blocking (Stage 7) ===================================
+ * A single process-global adblock engine (Brave's adblock-rust via its C FFI,
+ * vendored in qt-webengine/vendor/) filters network sub-resource requests in
+ * the request interceptor, built lazily from a small built-in starter filter
+ * list. Network-blocking only; cosmetic filtering is future work. The toggle is
+ * process-global (default ON). When built with -DJWB_ADBLOCK=OFF these are
+ * inert: set_enabled is a no-op and the getters return 0. */
+JWB_API int jwb_adblock_set_enabled(int on); /* returns the new state (1/0) */
+JWB_API int jwb_adblock_is_enabled(void);
+JWB_API int jwb_adblock_block_count(void);   /* requests blocked since start */
+JWB_API int jwb_adblock_available(void);      /* 1 if compiled in, else 0 */
+
 #ifdef __cplusplus
 } /* extern "C" */
 #endif
diff --git a/jerboa-browser.md b/jerboa-browser.md
index 38c0543..52b52b1 100644
--- a/jerboa-browser.md
+++ b/jerboa-browser.md
@@ -118,8 +118,11 @@ Qt WebEngine / Chromium
 Qt application window / browser pane
 ```
 
-No Rust layer is needed for the Qt WebEngine backend. Keep the ABI narrow so a
-future Servo backend can implement the same logical operations.
+The core Qt WebEngine backend needs no Rust. (Stage 7 later adds one *optional*
+vendored Rust staticlib — Brave's `adblock-rust` — linked into
+`libjerboa_browser` for ad/tracker blocking, gated by the `JWB_ADBLOCK` cmake
+option; it is not part of the C ABI. See ROADMAP.md Stage 7.) Keep the ABI
+narrow so a future Servo backend can implement the same logical operations.
 
 ## Repository layout
 
diff --git a/qt-webengine/CMakeLists.txt b/qt-webengine/CMakeLists.txt
index b8d6a6e..0c09f11 100644
--- a/qt-webengine/CMakeLists.txt
+++ b/qt-webengine/CMakeLists.txt
@@ -31,6 +31,32 @@ target_link_libraries(jerboa_browser
   PRIVATE Qt6::Core Qt6::Widgets Qt6::WebEngineWidgets
 )
 
+# --- ad / tracker blocking (Stage 7) ---------------------------------------
+# Brave's adblock-rust via its C FFI, vendored under vendor/adblock-rust-ffi.
+# cargo builds a staticlib (libadblock.a) that links into the jerboa_browser
+# shared lib; the interceptor in browser_policy.cpp calls engine_match(). Turn
+# off (-DJWB_ADBLOCK=OFF) to build without a Rust toolchain — blocking then
+# compiles out to no-ops.
+option(JWB_ADBLOCK "Ad/tracker blocking via vendored adblock-rust (needs cargo)" ON)
+if(JWB_ADBLOCK)
+  set(ADBLOCK_DIR ${CMAKE_CURRENT_SOURCE_DIR}/vendor/adblock-rust-ffi)
+  set(ADBLOCK_LIB ${ADBLOCK_DIR}/target/release/libadblock.a)
+  find_program(CARGO_EXE cargo REQUIRED)
+  add_custom_command(
+    OUTPUT ${ADBLOCK_LIB}
+    COMMAND ${CARGO_EXE} build --release --manifest-path ${ADBLOCK_DIR}/Cargo.toml
+    COMMENT "Building vendored adblock-rust staticlib (cargo build --release)"
+    VERBATIM)
+  add_custom_target(adblock_rust DEPENDS ${ADBLOCK_LIB})
+  add_dependencies(jerboa_browser adblock_rust)
+  target_compile_definitions(jerboa_browser PRIVATE JWB_ADBLOCK=1)
+  target_include_directories(jerboa_browser PRIVATE ${ADBLOCK_DIR}/src)
+  target_link_libraries(jerboa_browser PRIVATE ${ADBLOCK_LIB})
+  if(NOT APPLE)
+    target_link_libraries(jerboa_browser PRIVATE pthread dl)
+  endif()
+endif()
+
 # Smoke-test / demo driver. Grows into the load+eval demo in Ticket 1.2.
 add_executable(jwb_demo src/main_demo.cpp)
 target_link_libraries(jwb_demo PRIVATE jerboa_browser)
diff --git a/qt-webengine/src/browser_policy.cpp b/qt-webengine/src/browser_policy.cpp
index 4c6bc4b..76458f7 100644
--- a/qt-webengine/src/browser_policy.cpp
+++ b/qt-webengine/src/browser_policy.cpp
@@ -1,9 +1,14 @@
-// browser_policy.cpp — deny-by-default capability policy on a context's profile.
+// browser_policy.cpp — deny-by-default capability policy + ad/tracker blocking.
 //
-// Ticket 1.3. Each capability absent from the context's flags removes the
-// corresponding authority: network requests are blocked, downloads cancelled,
-// popups refused, local-file and clipboard access disabled. DevTools/remote
-// debugging is opt-in elsewhere and never enabled here.
+// Ticket 1.3 + Stage 7. Each capability absent from the context's flags removes
+// the corresponding authority: network requests are blocked, downloads
+// cancelled, popups refused, local-file and clipboard access disabled.
+// On top of that, a single process-global adblock engine (Brave's adblock-rust
+// via its C FFI, vendored under qt-webengine/vendor/) filters network
+// sub-resource requests in the interceptor — built lazily from a small built-in
+// starter filter list and toggled at runtime (default on). The adblock layer is
+// compiled only when JWB_ADBLOCK is defined; otherwise the jwb_adblock_* entry
+// points are inert no-ops and the interceptor does only capability gating.
 #include "browser_objects.h"
 
 #include <QWebEngineDownloadRequest>
@@ -14,16 +19,155 @@
 #include <QLatin1String>
 #include <QString>
 
+#include <atomic>
 #include <cstdio>
+#include <cstring>
+
+#include "jerboa_browser.h"
+
+#ifdef JWB_ADBLOCK
+extern "C" {
+#include "lib.h" // adblock-rust-ffi (vendored): engine_create/_match/_destroy
+}
+#endif
 
 namespace {
 
-// When network is not granted, only in-page / local schemes pass; everything
-// else is blocked before leaving the process.
+#ifdef JWB_ADBLOCK
+// --- adblock-rust process-global engine -------------------------------------
+// The QWebEngine request interceptor runs on the UI thread, so the engine is
+// effectively single-threaded; the toggle and counter are still atomic so the
+// jwb_adblock_* ABI can be poked from the Scheme run-loop without a data race.
+std::atomic<bool> g_adblock_enabled{true};
+std::atomic<long> g_adblock_blocked{0};
+C_Engine *g_adblock_engine = nullptr; // built once, on the UI thread
+
+// A small, uncontroversial starter list of ad/tracker hostnames in ABP syntax.
+// Network rules only (cosmetic filtering is future work); `||host^` matches the
+// host regardless of resource type / first-party, so the naive resolver below
+// is sufficient. Loading a full list (EasyList) at runtime is a planned
+// follow-up — see jerboa-browser.md.
+const char *kSeedFilters =
+    "! jerboa-browser built-in starter list\n"
+    "||doubleclick.net^\n"
+    "||googlesyndication.com^\n"
+    "||google-analytics.com^\n"
+    "||googletagmanager.com^\n"
+    "||googletagservices.com^\n"
+    "||googleadservices.com^\n"
+    "||adservice.google.com^\n"
+    "||g.doubleclick.net^\n"
+    "||2mdn.net^\n"
+    "||amazon-adsystem.com^\n"
+    "||adnxs.com^\n"
+    "||adsrvr.org^\n"
+    "||rubiconproject.com^\n"
+    "||pubmatic.com^\n"
+    "||casalemedia.com^\n"
+    "||criteo.com^\n"
+    "||criteo.net^\n"
+    "||taboola.com^\n"
+    "||outbrain.com^\n"
+    "||scorecardresearch.com^\n"
+    "||quantserve.com^\n"
+    "||moatads.com^\n"
+    "||doubleverify.com^\n"
+    "||serving-sys.com^\n"
+    "||bluekai.com^\n"
+    "||hotjar.com^\n"
+    "||mixpanel.com^\n"
+    "||segment.io^\n"
+    "||connect.facebook.net^\n"
+    "||facebook.com/tr\n"
+    "||ads.yahoo.com^\n"
+    "||adcolony.com^\n";
+
+// adblock-rust requires a domain resolver before any matching. This naive one
+// treats the whole hostname as the registrable domain (start=0), which is
+// sufficient for the hostname-anchored starter rules; a public-suffix-aware
+// resolver is future work.
+void adblock_domain_resolver(const char *host, uint32_t *start, uint32_t *end) {
+  *start = 0;
+  *end = host ? static_cast<uint32_t>(std::strlen(host)) : 0;
+}
+
+C_Engine *adblock_engine() {
+  if (!g_adblock_engine) {
+    static bool resolver_set = false;
+    if (!resolver_set) {
+      set_domain_resolver(adblock_domain_resolver);
+      resolver_set = true;
+    }
+    g_adblock_engine = engine_create(kSeedFilters);
+  }
+  return g_adblock_engine;
+}
+
+// eTLD+1 approximated by the last two labels (no public-suffix list) — used
+// only to decide first-party vs third-party; rule matching uses the resolver.
+QString last_two_labels(const QString &host) {
+  const int dot = host.lastIndexOf(QLatin1Char('.'));
+  if (dot <= 0) return host;
+  const int dot2 = host.lastIndexOf(QLatin1Char('.'), dot - 1);
+  return dot2 < 0 ? host : host.mid(dot2 + 1);
+}
+
+// Map Qt's resource type to the adblock-rust request-type strings.
+const char *adblock_resource_type(QWebEngineUrlRequestInfo::ResourceType t) {
+  using R = QWebEngineUrlRequestInfo;
+  switch (t) {
+  case R::ResourceTypeMainFrame:    return "document";
+  case R::ResourceTypeSubFrame:     return "sub_frame";
+  case R::ResourceTypeStylesheet:   return "stylesheet";
+  case R::ResourceTypeScript:       return "script";
+  case R::ResourceTypeImage:        return "image";
+  case R::ResourceTypeFontResource: return "font";
+  case R::ResourceTypeObject:       return "object";
+  case R::ResourceTypeMedia:        return "media";
+  case R::ResourceTypeXhr:          return "xmlhttprequest";
+  case R::ResourceTypePing:         return "ping";
+  case R::ResourceTypeFavicon:      return "image";
+  case R::ResourceTypeWebSocket:    return "websocket";
+  default:                          return "other";
+  }
+}
+
+bool adblock_should_block(QWebEngineUrlRequestInfo &info) {
+  C_Engine *e = adblock_engine();
+  if (!e) return false;
+  const QByteArray url8 = info.requestUrl().toString().toUtf8();
+  const QString host = info.requestUrl().host();
+  const QString fp = info.firstPartyUrl().host();
+  const QByteArray host8 = host.toUtf8();
+  const QByteArray fp8 = fp.toUtf8();
+  const bool third_party =
+      !fp.isEmpty() && last_two_labels(host).compare(
+                           last_two_labels(fp), Qt::CaseInsensitive) != 0;
+  bool matched = false, exception = false, important = false;
+  char *redirect = nullptr;
+  engine_match(e, url8.constData(), host8.constData(),
+               fp8.isEmpty() ? host8.constData() : fp8.constData(), third_party,
+               adblock_resource_type(info.resourceType()), &matched, &exception,
+               &important, &redirect);
+  if (redirect) c_char_buffer_destroy(redirect);
+  return matched && !exception;
+}
+#endif // JWB_ADBLOCK
+
+// The interceptor applies adblock first (when enabled), then the network
+// capability gate: with network granted only adblock filters; without it, only
+// in-page / local schemes pass and everything else is blocked in-process.
 class NetworkInterceptor : public QWebEngineUrlRequestInterceptor {
 public:
   explicit NetworkInterceptor(bool allow_network) : allow_(allow_network) {}
   void interceptRequest(QWebEngineUrlRequestInfo &info) override {
+#ifdef JWB_ADBLOCK
+    if (g_adblock_enabled.load() && adblock_should_block(info)) {
+      g_adblock_blocked.fetch_add(1);
+      info.block(true);
+      return;
+    }
+#endif
     if (allow_) return;
     const QString scheme = info.requestUrl().scheme();
     if (scheme == QLatin1String("about") || scheme == QLatin1String("data") ||
@@ -47,6 +191,10 @@ void install_policy(Context *c) {
   const bool filesystem = c->caps & JWB_CAP_FILESYSTEM;
   const bool clipboard = c->caps & JWB_CAP_CLIPBOARD;
 
+#ifdef JWB_ADBLOCK
+  adblock_engine(); // build the engine eagerly so the first request is cheap
+#endif
+
   auto *intc = new NetworkInterceptor(network);
   c->interceptor = intc;
   c->profile->setUrlRequestInterceptor(intc);
@@ -73,3 +221,40 @@ void install_policy(Context *c) {
 }
 
 } // namespace jwb
+
+// --- adblock toggle ABI (jerboa_browser.h) ----------------------------------
+// Declared extern "C" + JWB_API in the header; defined here next to the engine.
+// Inert no-ops when JWB_ADBLOCK is not defined.
+int jwb_adblock_set_enabled(int on) {
+#ifdef JWB_ADBLOCK
+  g_adblock_enabled.store(on != 0);
+  return g_adblock_enabled.load() ? 1 : 0;
+#else
+  (void)on;
+  return 0;
+#endif
+}
+
+int jwb_adblock_is_enabled(void) {
+#ifdef JWB_ADBLOCK
+  return g_adblock_enabled.load() ? 1 : 0;
+#else
+  return 0;
+#endif
+}
+
+int jwb_adblock_block_count(void) {
+#ifdef JWB_ADBLOCK
+  return static_cast<int>(g_adblock_blocked.load());
+#else
+  return 0;
+#endif
+}
+
+int jwb_adblock_available(void) {
+#ifdef JWB_ADBLOCK
+  return 1;
+#else
+  return 0;
+#endif
+}
diff --git a/qt-webengine/tests/CMakeLists.txt b/qt-webengine/tests/CMakeLists.txt
index 2befbc8..0ff4bec 100644
--- a/qt-webengine/tests/CMakeLists.txt
+++ b/qt-webengine/tests/CMakeLists.txt
@@ -14,3 +14,16 @@ target_include_directories(jwb_abi_test PRIVATE
 )
 
 add_test(NAME jwb_abi_test COMMAND jwb_abi_test)
+
+# Stage 7 — adblock-rust FFI match semantics. Pure C++; links the vendored
+# staticlib directly (no Qt). Only built when JWB_ADBLOCK is enabled.
+if(JWB_ADBLOCK)
+  add_executable(jwb_adblock_test adblock_test.cc)
+  add_dependencies(jwb_adblock_test adblock_rust)
+  target_include_directories(jwb_adblock_test PRIVATE ${ADBLOCK_DIR}/src)
+  target_link_libraries(jwb_adblock_test PRIVATE ${ADBLOCK_LIB})
+  if(NOT APPLE)
+    target_link_libraries(jwb_adblock_test PRIVATE pthread dl)
+  endif()
+  add_test(NAME jwb_adblock_test COMMAND jwb_adblock_test)
+endif()
diff --git a/qt-webengine/tests/adblock_test.cc b/qt-webengine/tests/adblock_test.cc
new file mode 100644
index 0000000..dfcbe76
--- /dev/null
+++ b/qt-webengine/tests/adblock_test.cc
@@ -0,0 +1,65 @@
+// adblock_test.cc — hermetic unit test for the vendored adblock-rust FFI.
+//
+// Links libadblock.a directly (no Qt, no network, no QApplication) and exercises
+// the exact match semantics browser_policy.cpp relies on: a request is blocked
+// iff did_match_rule && !did_match_exception. Built only when JWB_ADBLOCK is on.
+extern "C" {
+#include "lib.h"
+}
+#include <cstdio>
+#include <cstring>
+
+static int failures = 0;
+
+// Naive resolver, matching browser_policy.cpp: whole host == domain.
+static void resolver(const char *host, uint32_t *s, uint32_t *e) {
+  *s = 0;
+  *e = host ? static_cast<uint32_t>(std::strlen(host)) : 0;
+}
+
+static void check(C_Engine *e, const char *url, const char *host,
+                  bool third_party, bool want_block, const char *desc) {
+  bool matched = false, exception = false, important = false;
+  char *redirect = nullptr;
+  engine_match(e, url, host, "example.com", third_party, "image", &matched,
+               &exception, &important, &redirect);
+  if (redirect) c_char_buffer_destroy(redirect);
+  const bool blocked = matched && !exception;
+  if (blocked != want_block) {
+    std::printf("FAIL: %s (%s) -> blocked=%d want=%d\n", desc, url, blocked,
+                want_block);
+    ++failures;
+  } else {
+    std::printf("ok: %s\n", desc);
+  }
+}
+
+int main() {
+  if (!set_domain_resolver(resolver)) {
+    std::printf("FAIL: set_domain_resolver returned false\n");
+    return 1;
+  }
+  C_Engine *e = engine_create("||ads.example^\n"
+                              "||tracker.example^\n"
+                              "@@||tracker.example/allowed^\n"
+                              "||doubleclick.net^\n");
+  if (!e) {
+    std::printf("FAIL: engine_create returned null\n");
+    return 1;
+  }
+  check(e, "http://ads.example/banner.gif", "ads.example", true, true,
+        "ad host blocked");
+  check(e, "http://safe.example/pic.gif", "safe.example", true, false,
+        "safe host allowed");
+  check(e, "http://tracker.example/x.js", "tracker.example", true, true,
+        "tracker blocked");
+  check(e, "http://tracker.example/allowed/x.js", "tracker.example", true,
+        false, "exception rule allows");
+  check(e, "https://2mdn.doubleclick.net/ad", "2mdn.doubleclick.net", true,
+        true, "real ad host (doubleclick) blocked");
+  check(e, "https://example.com/index.html", "example.com", false, false,
+        "first-party page allowed");
+  engine_destroy(e);
+  std::printf("%s (%d failure(s))\n", failures ? "FAILED" : "PASSED", failures);
+  return failures ? 1 : 0;
+}
diff --git a/scheme/browser-adblock-test.ss b/scheme/browser-adblock-test.ss
new file mode 100644
index 0000000..69a12b0
--- /dev/null
+++ b/scheme/browser-adblock-test.ss
@@ -0,0 +1,129 @@
+#!chezscheme
+;;; browser-adblock-test.ss — Stage 7 ad/tracker blocking (adblock-rust).
+;;;
+;;; The match engine itself (engine_create / engine_match semantics: a request
+;;; is blocked iff did_match_rule && !did_match_exception) is unit-tested in C++
+;;; at qt-webengine/tests/adblock_test.cc — run with `ctest -R adblock`.
+;;;
+;;; This file covers the Scheme surface: the process-global toggle ABI wrappers
+;;; (browser-adblock-*) and the `toggle-adblock` command + echo wiring + its
+;;; C-c b binding. Hermetic: no network, offscreen. Opening a session also
+;;; exercises engine_create with the built-in starter list (install_policy
+;;; builds the engine eagerly), so a broken seed list would surface here too.
+;;;
+;;; Run:  QT_QPA_PLATFORM=offscreen make test-adblock
+;;; Exits 0 on success, 1 on any failed case.
+
+(import (chezscheme) (browser) (browser buffers)
+        (browser keymap) (browser minibuffer) (browser commands))
+
+;;; ─── tiny test framework (shared shape with the other test files) ─────────
+(define *pass* 0)
+(define *fail* 0)
+(define *test-name* "(none)")
+
+(define-syntax test-group
+  (syntax-rules ()
+    [(_ name body ...)
+     (begin (display "\n=== ") (display name) (display " ===\n")
+            (flush-output-port (current-output-port)) body ...)]))
+
+(define (run-test-case name thunk)
+  (set! *test-name* name)
+  (let ((ok (guard (e (#t
+                       (set! *fail* (+ *fail* 1))
+                       (display "  FAIL: ") (display name) (newline)
+                       (display "    error: ")
+                       (display (if (message-condition? e) (condition-message e)
+                                    (format "~s" e)))
+                       (newline) (flush-output-port (current-output-port)) #f))
+              (thunk) #t)))
+    (when ok
+      (set! *pass* (+ *pass* 1))
+      (display "  pass: ") (display name) (newline)
+      (flush-output-port (current-output-port)))))
+
+(define-syntax test-case
+  (syntax-rules () [(_ name body ...) (run-test-case name (lambda () body ...))]))
+
+(define-syntax check
+  (syntax-rules (=>)
+    [(_ expr => expected)
+     (let ((got expr) (exp expected))
+       (unless (equal? got exp)
+         (error 'check (format "~a: expected ~s, got ~s" *test-name* exp got))))]))
+
+(unless (getenv "QT_QPA_PLATFORM")
+  (error 'setup "run under QT_QPA_PLATFORM=offscreen (use: make test-adblock)"))
+
+;; Is `needle` a substring of `hay`?
+(define (str-has? hay needle)
+  (let ((hn (string-length hay)) (nn (string-length needle)))
+    (let loop ((i 0))
+      (cond ((> (+ i nn) hn) #f)
+            ((string=? (substring hay i (+ i nn)) needle) #t)
+            (else (loop (+ i 1)))))))
+
+;; A hermetic page: no-network session, window shown + sized + pumped, controller.
+(define-syntax with-page
+  (syntax-rules ()
+    [(_ (app s v) url body ...)
+     (let* ((s (open-browser-session (browser-capabilities)))   ; no network
+            (win (session-window s)))
+       (browser-window-resize win 800 600)
+       (browser-window-show win)
+       (session-open-buffer s url)
+       (browser-pump 300)
+       (let* ((app (make-browser-app s))
+              (v (buffer-view (session-current-buffer s))))
+         body ...
+         (close-browser-session! s)))]))
+
+(define blank-page "data:text/html,<html><body>blank</body></html>")
+
+;;; ─── toggle ABI wrappers (process-global) ───────────────────────────────────
+(test-group "adblock toggle ABI"
+  (test-case "compiled in (JWB_ADBLOCK); default enabled"
+    (check (browser-adblock-available?) => #t)
+    (browser-adblock-set! #t)                       ; normalize to a known state
+    (check (browser-adblock-enabled?) => #t))
+  (test-case "set! flips the live state and returns the new state"
+    (check (browser-adblock-set! #f) => #f)
+    (check (browser-adblock-enabled?) => #f)
+    (check (browser-adblock-set! #t) => #t)
+    (check (browser-adblock-enabled?) => #t))
+  (test-case "block-count is a non-negative integer"
+    (let ((n (browser-adblock-block-count)))
+      (check (and (integer? n) (>= n 0)) => #t))))
+
+;;; ─── opening a session builds the engine from the starter list ──────────────
+(test-group "engine builds with the built-in starter list"
+  (test-case "a no-network session opens cleanly (install_policy → engine_create)"
+    (with-page (app s v) blank-page
+      (check (and (browser-view? v) #t) => #t)
+      (check (browser-adblock-enabled?) => #t))))   ; still on after a context
+
+;;; ─── toggle-adblock command + echo + C-c b binding ──────────────────────────
+(test-group "toggle-adblock command"
+  (test-case "registered + documented"
+    (check (and (command-ref 'toggle-adblock) #t) => #t))
+  (test-case "C-c b flips blocking off then on, echoing each state"
+    (with-page (app s v) blank-page
+      (browser-adblock-set! #t)                     ; known start: ON
+      (app-feed-token! app "C-c") (app-feed-token! app "b")
+      (check (browser-adblock-enabled?) => #f)
+      (check (str-has? (app-echo app) "off") => #t)
+      (app-feed-token! app "C-c") (app-feed-token! app "b")
+      (check (browser-adblock-enabled?) => #t)
+      (check (str-has? (app-echo app) "Ad blocking on") => #t)))
+  (test-case "run-command-by-name toggles too"
+    (with-page (app s v) blank-page
+      (browser-adblock-set! #t)
+      (run-command-by-name app "toggle-adblock")
+      (check (browser-adblock-enabled?) => #f)
+      (browser-adblock-set! #t))))                  ; restore ON
+
+(newline)
+(display "browser-adblock-test: ") (display *pass*) (display " passed, ")
+(display *fail*) (display " failed") (newline)
+(exit (if (zero? *fail*) 0 1))
diff --git a/scheme/browser.ss b/scheme/browser.ss
index a043e36..97f9035 100644
--- a/scheme/browser.ss
+++ b/scheme/browser.ss
@@ -65,6 +65,11 @@
     browser-clear-find
     browser-clipboard-set!
     browser-clipboard
+    ;; ad / tracker blocking (Stage 7)
+    browser-adblock-available?
+    browser-adblock-enabled?
+    browser-adblock-set!
+    browser-adblock-block-count
     ;; window / buffer chrome
     browser-window-open
     browser-window-close
@@ -189,6 +194,11 @@
   (define-c-lambda %find         (uint64 char-string int uint32) int     "jwb_view_find")
   (define-c-lambda %clip-set     (char-string)               int         "jwb_clipboard_set")
   (define-c-lambda %clip-get     ()                          char-string "jwb_clipboard_get")
+  ;; ad / tracker blocking (browser_policy.cpp): process-global engine + toggle
+  (define-c-lambda %adblock-set   (int) int "jwb_adblock_set_enabled")
+  (define-c-lambda %adblock-on?   ()    int "jwb_adblock_is_enabled")
+  (define-c-lambda %adblock-count ()    int "jwb_adblock_block_count")
+  (define-c-lambda %adblock-avail ()    int "jwb_adblock_available")
   ;; window / buffer chrome (browser_window.cpp)
   (define-c-lambda %win-open       ()                        uint64      "jwb_window_open")
   (define-c-lambda %win-close      (uint64)                  int         "jwb_window_close")
@@ -421,6 +431,14 @@
         (err* (string-append "clipboard-set: " (%last-error)))))
   (def (browser-clipboard) (%clip-get))
 
+  ;; Ad / tracker blocking. Process-global (not view-scoped): one engine + one
+  ;; toggle for the whole browser, applied in the request interceptor.
+  ;; browser-adblock-available? is #f when built with -DJWB_ADBLOCK=OFF.
+  (def (browser-adblock-available?)  (= 1 (%adblock-avail)))
+  (def (browser-adblock-enabled?)    (= 1 (%adblock-on?)))
+  (def (browser-adblock-set! on)     (= 1 (%adblock-set (if on 1 0))))
+  (def (browser-adblock-block-count) (%adblock-count))
+
   ;; --- window / buffer chrome (browser_window.cpp) -----------------------
   ;; A browser-window is the nyxt-style single window hosting many views; the
   ;; (browser buffers) module layers the buffer list/switch logic on top.
diff --git a/scheme/browser/commands.ss b/scheme/browser/commands.ss
index 5d28e58..0150fc4 100644
--- a/scheme/browser/commands.ss
+++ b/scheme/browser/commands.ss
@@ -97,7 +97,10 @@
   ;; Active minor modes, nyxt-style, for the status line: the base keyscheme is
   ;; always on; hint-mode shows while hinting. (Shown as "[emacs]" / "[emacs hint]".)
   (def (app-modes-string app)
-    (string-append "emacs" (if (app-hints app) " hint" "")))
+    (string-append "emacs"
+                   (if (app-hints app) " hint" "")
+                   (if (and (browser-adblock-available?) (browser-adblock-enabled?))
+                       " block" "")))
 
   ;; Reflect controller state into the window chrome. The minibuffer line doubles
   ;; as the echo area (as in emacs): while a prompt is open it shows the prompt;
@@ -567,6 +570,7 @@
       ("C-s"     . find-in-page)
       ("C-M-s"   . find-next)
       ("C-M-r"   . find-previous)
+      ("C-c b"   . toggle-adblock)
       ("C-c u"   . copy-url)
       ("C-c t"   . copy-title)
       ;; hint-mode (Stage 4) — nyxt link hinting / follow
@@ -674,6 +678,19 @@
   (register-command! 'copy-title "Copy the current buffer's title to the clipboard."
     (lambda (app) (copy-current! app "title" browser-title)))
 
+  ;; --- ad / tracker blocking (Stage 7) ------------------------------------
+  (register-command! 'toggle-adblock "Toggle ad/tracker blocking (process-global; default on)."
+    (lambda (app)
+      (if (browser-adblock-available?)
+          (let ((on (browser-adblock-set! (not (browser-adblock-enabled?)))))
+            (app-echo-set! app
+              (if on
+                  (string-append "Ad blocking on ("
+                                 (number->string (browser-adblock-block-count))
+                                 " blocked)")
+                  "Ad blocking off")))
+          (app-echo-set! app "Ad blocking not available in this build"))))
+
   ;; --- hint-mode commands -------------------------------------------------
   (register-command! 'follow-hint "Hint links/controls and follow the chosen one."
     (lambda (app) (start-hint-mode! app #f)))