diff --git a/AGENTS.md b/AGENTS.md index 592c81d..150f3d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,15 +12,15 @@ Do not edit `places.sqlite` while Firefox is open if this API is reachable. 2. Native host installed only if scripts should call in: `powershell -NoProfile -File tools/install-native-host.ps1` 3. Firefox **running** for those calls -If `http://127.0.0.1:17634/health` fails, the host is not up. If `/health` works but calls 401, you do not have a current `FAB_TOKEN`. +If `http://127.0.0.1:17634/health` fails, the host is not up. If `/health` works but calls 401, you do not have a current token. ## Auth This is a normal API token, not an SSH key. 1. In Firefox, open the Bookmarks API toolbar icon → **Manage clients**. -2. Name the client (e.g. `cursor-agent`) and click **Generate secret**. -3. Copy the `fab_…` value **once**. Store it however you already store secrets: env var, Vaultwarden, `.env`, CI secret, Cursor env. +2. Name the client and click **Generate secret**. +3. Copy the `fab_…` value **once**. Store it with your other secrets (environment variable, password manager, `.env`, or CI secret). 4. Give tooling only that string: ```text @@ -41,7 +41,7 @@ python tools/client.py METHOD [ARGS_JSON] `ARGS_JSON` is a JSON **array** matching the WebExtension function arguments. ```text -python tools/client.py bookmarks.search "[{\"title\":\"10.132.x.x\"}]" +python tools/client.py bookmarks.search "[{\"title\":\"Research\"}]" ``` HTTP: @@ -51,10 +51,10 @@ POST /v1/call Authorization: Bearer fab_… Content-Type: application/json -{"method": "bookmarks.search", "args": [{"title": "10.132.x.x"}]} +{"method": "bookmarks.search", "args": [{"title": "Research"}]} ``` -## Allowlisted methods (v0.2) +## Allowlisted methods | Method | Args | Notes | |--------|------|--------| @@ -72,7 +72,7 @@ Content-Type: application/json | `bookmarks.removeTree` | `id` | Folder and descendants | | `bookmarks.getRecent` | `numberOfItems` | | -Do not invent other `browser.*` names. Adding an API means editing `ALLOWED` in `extension/background.js` and reloading the extension. +Do not invent other `browser.*` names. Adding an API means editing `ALLOWED` in `extension/background.js` **and** `ALLOWED_METHODS` in `host/firefox_agent_bridge_host.py`, then reloading the extension. REST aliases use the same Bearer token. `GET /health` has no token (loopback liveness only). @@ -92,7 +92,7 @@ REST aliases use the same Bearer token. `GET /health` has no token (loopback liv | Connection refused on `:17634` | Firefox closed or add-on/host missing | Open Firefox; load add-on; install native host | | 401 `Bearer token required` | Unsigned request | Set `FAB_TOKEN` | | 401 `unknown token` | Revoked, typo, or never generated | Manage clients → generate again | -| 502 `method not allowed` | Typo or API not in `ALLOWED` | `meta.methods` | +| 502 `method not allowed` | Typo or API not in the allowlist | `meta.methods` | | Temporary add-on gone after restart | Unsigned on Firefox Release | Load again, or sign via AMO | ## Security boundaries diff --git a/LICENSE b/LICENSE index f6dbc02..95c35f8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 EasyGoin / EGG +Copyright (c) 2026 EasyGoin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..3f9474d --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,23 @@ +# Privacy + +Bookmarks API for Scripting and AI runs on your computer. It does not send your bookmarks, secrets, or browsing data to the authors or to any remote service. + +## What it can see + +With the `bookmarks` permission, the add-on can read and change bookmarks in the Firefox profile where it is installed. That is the product. Local scripts you authorize can request the same operations through the loopback API. + +## What it stores + +Manage clients stores **SHA-256 hashes** of issued secrets, plus the client name and timestamps, in `browser.storage.local`. The plaintext secret is shown once and is not kept. + +The add-on does not use `storage.sync`, so those hashes are not uploaded with a Firefox Account. + +## What it does not collect + +No telemetry, crash reports, or analytics. No account. No network calls to the authors. + +The optional native host listens on `127.0.0.1` only so *your* local tools can reach the add-on. It does not accept browser-origin requests. + +## Revoking access + +Toolbar → **Manage clients** → **Revoke**. After that, the old secret is rejected. diff --git a/README.md b/README.md index 066d122..aab6503 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,59 @@ # Bookmarks API for Scripting and AI -An API add-on for Firefox to allow agentic and script-based management of user bookmarks. +An API add-on for Firefox so agents and scripts can manage your bookmarks while the browser is open. -The published product is the add-on in `extension/`. Issue a secret in **Manage clients**, store it with your secrets, and keep Firefox open so those tools can talk to this add-on. +The shipped product is the add-on in `extension/`. This is not a bookmark sync engine, and it is not a Mozilla product. -This is not a bookmark sync engine, and it is not a Mozilla product. +## What it does -## Setup +Scripts and local agents call a loopback HTTP API. The add-on performs only `browser.bookmarks` operations (create, search, move, delete, and related reads). It does not read tabs, history, cookies, or the open web. -1. Load the add-on (temporary via `about:debugging`, or a signed `.xpi` — [docs/signing.md](docs/signing.md)). -2. Toolbar → **Manage clients** → generate a secret → store it with your secrets. -3. To let scripts call in, register the native host once (`tools/install-native-host.ps1` on this workstation). +1. Load the add-on. +2. Toolbar → **Manage clients** → generate a secret → store that secret yourself. +3. Register the native host once if scripts should call in (`tools/install-native-host.ps1` on Windows). +4. Keep Firefox open. Call `http://127.0.0.1:17634` with `Authorization: Bearer `. -Authenticated calls use `Authorization: Bearer ` on `http://127.0.0.1:17634`. Method list for people working in this repo: [AGENTS.md](AGENTS.md). +```http +POST /v1/call +Authorization: Bearer fab_… +Content-Type: application/json + +{"method": "bookmarks.search", "args": [{"title": "Research"}]} +``` + +```text +set FAB_TOKEN=fab_… +python tools/client.py meta.methods +python tools/client.py bookmarks.search "[{\"title\":\"Research\"}]" +``` + +Method list and failure modes: [AGENTS.md](AGENTS.md). Signing for Firefox Release: [docs/signing.md](docs/signing.md). + +## Permissions + +| Permission | Why | +|------------|-----| +| `bookmarks` | The only WebExtension API this add-on calls | +| `storage` | SHA-256 hashes of issued client secrets | +| `nativeMessaging` | The only inbound path from local scripts (Mozilla does not allow a raw listening socket in the extension) | + +It does **not** request `tabs`, `history`, `cookies`, ``, `webRequest`, or `runtime.onMessageExternal`. ## Security -- Binds **127.0.0.1** only. Browser `Origin` headers are rejected. -- Client hashes live in the add-on’s storage. The host asks the add-on whether a token is valid. -- Only `bookmarks.*` (plus `meta.*`). No history, cookies, tabs, or `onMessageExternal`. +- HTTP binds **127.0.0.1** only. Requests with a browser `Origin` header are rejected. +- The native host may talk only to this add-on’s gecko id. +- The add-on stores **hashes**, not secrets. Revoke from Manage clients. +- Both the host and the add-on allowlist the same `meta.*` / `bookmarks.*` methods. + +See [PRIVACY.md](PRIVACY.md) and [SECURITY.md](SECURITY.md). + +## What this is not + +- Not bidirectional bookmark sync +- Not a general Firefox remote-control surface +- Not a reason to edit `places.sqlite` while Firefox is running ## License -MIT. +MIT. Copyright EasyGoin. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d941334 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,16 @@ +# Security + +This add-on is scoped to bookmarks. Treat a leaked `fab_…` secret like a password for your bookmark tree: revoke it, then issue a new one. + +## Boundaries that must stay + +- Manifest permissions: `bookmarks`, `storage`, `nativeMessaging` only +- No `tabs`, `history`, `cookies`, ``, `webRequest`, or `runtime.onMessageExternal` +- Host bind: `127.0.0.1` +- Reject HTTP requests that carry a browser `Origin` +- Native host `allowed_extensions` pinned to this gecko id +- Method allowlist in both `extension/background.js` and `host/firefox_agent_bridge_host.py` + +## Reporting + +Please open an issue on this repository. Do not attach live secrets or a copy of `places.sqlite`. diff --git a/docs/signing.md b/docs/signing.md index fcc4969..3b5ef8c 100644 --- a/docs/signing.md +++ b/docs/signing.md @@ -1,6 +1,6 @@ # Signing for Firefox Release -Firefox Release and Beta will only keep an add-on that Mozilla has signed. Temporary add-ons from `about:debugging` die on restart. Developer Edition / Nightly can load unsigned builds if `xpinstall.signatures.required` is false — do not rely on that for this workstation. +Firefox Release and Beta will only keep an add-on that Mozilla has signed. Temporary add-ons from `about:debugging` are removed on restart. Developer Edition / Nightly can load unsigned builds if `xpinstall.signatures.required` is false — do not rely on that for daily use. Mozilla signs through [addons.mozilla.org](https://addons.mozilla.org/) even when the add-on is **not** listed in the store. @@ -9,14 +9,14 @@ Mozilla signs through [addons.mozilla.org](https://addons.mozilla.org/) even whe 1. Register at [addons.mozilla.org](https://addons.mozilla.org/) (Mozilla account). 2. Open [API credentials](https://addons.mozilla.org/developers/addon/api/key/). 3. Generate a JWT **issuer** (`user:…`) and **secret**. -4. Store both in Vaultwarden. Never commit them. Env names `WEB_EXT_API_KEY` / `WEB_EXT_API_SECRET` (or `AMO_JWT_ISSUER` / `AMO_JWT_SECRET` if you prefer wrappers). +4. Store both in your password manager. Never commit them. Env names `WEB_EXT_API_KEY` / `WEB_EXT_API_SECRET`. ## 2. Choose a channel | Channel | What you get | |---------|----------------| | **unlisted** | Signed `.xpi` for self-install. Not on the AMO store. Enough to survive Firefox restarts. **Start here.** | -| **listed** | Public AMO listing, automatic updates via Firefox. Needs listing metadata, review, and Add-on Policy compliance. Do this when you want strangers to find it. | +| **listed** | Public AMO listing, automatic updates via Firefox. Needs listing metadata, review, and Add-on Policy compliance. | Unlisted submissions can still be pulled for manual review. Native messaging is allowed; be ready to explain that the HTTP listener is the **native host**, bind is loopback-only, tokens are hashed at rest, and the extension does not accept `onMessageExternal`. @@ -30,16 +30,16 @@ cd extension web-ext sign --channel=unlisted --api-key $env:WEB_EXT_API_KEY --api-secret $env:WEB_EXT_API_SECRET ``` -The gecko id in `manifest.json` (`firefox-agent-bridge@easygoingaming.com`) must stay stable. Bump `version` for every new sign. +The gecko id in `manifest.json` must stay stable. Bump `version` for every new sign. -`web-ext` writes a signed `.xpi` under `web-ext-artifacts/`. Install it in Firefox: the `.xpi` file, or `about:addons` → gear → Install Add-on From File. +`web-ext` writes a signed `.xpi` under `web-ext-artifacts/`. Install it in Firefox from that file, or `about:addons` → gear → Install Add-on From File. Keep the native host registered (`tools/install-native-host.ps1`). Signing replaces only the extension; the host is unchanged. ## 4. Updates -Bump `version`, sign again on the same channel and id. For unlisted self-distribution, [updates](https://extensionworkshop.com/documentation/manage/updating-your-extension/) need an `update_url` in `browser_specific_settings.gecko` if you want Firefox to auto-fetch. Until that exists, drop in a new `.xpi` by hand. +Bump `version`, sign again on the same channel and id. For unlisted self-distribution, [updates](https://extensionworkshop.com/documentation/manage/updating-your-extension/) need an `update_url` in `browser_specific_settings.gecko` if you want Firefox to auto-fetch. Until that exists, install a new `.xpi` by hand. ## 5. Listed (later) -`web-ext sign --channel=listed` plus an AMO metadata JSON (name **Bookmarks API for Scripting and AI**, license MIT). Listing copy: local scripts only, loopback HTTP, bearer token, bookmarks allowlist. Do not title it “Firefox …”. Mozilla only allows “Name for Firefox”; this add-on uses neither form. +`web-ext sign --channel=listed` plus AMO metadata (name **Bookmarks API for Scripting and AI**, license MIT, privacy policy from [PRIVACY.md](../PRIVACY.md)). Listing copy: local scripts only, loopback HTTP, bearer token, bookmarks allowlist. Do not title it “Firefox …”. Mozilla only allows “Name for Firefox”; this add-on uses neither form. diff --git a/host/firefox_agent_bridge_host.py b/host/firefox_agent_bridge_host.py index 6a08d48..22609e3 100644 --- a/host/firefox_agent_bridge_host.py +++ b/host/firefox_agent_bridge_host.py @@ -10,7 +10,6 @@ import sys import threading import uuid from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from pathlib import Path from typing import Any from urllib.parse import parse_qs, urlparse @@ -24,7 +23,23 @@ HOST = "127.0.0.1" PORT = int(os.environ.get("FAB_PORT", "17634")) MAX_BODY = 256 * 1024 ALLOWED_HOSTS = {f"127.0.0.1:{PORT}", f"localhost:{PORT}"} -STATE_DIR = Path(os.environ.get("LOCALAPPDATA", str(Path.home()))) / "firefox-agent-bridge" +ALLOWED_METHODS = frozenset( + { + "meta.ping", + "meta.methods", + "bookmarks.getTree", + "bookmarks.getSubTree", + "bookmarks.getChildren", + "bookmarks.get", + "bookmarks.search", + "bookmarks.create", + "bookmarks.update", + "bookmarks.move", + "bookmarks.remove", + "bookmarks.removeTree", + "bookmarks.getRecent", + } +) STDIN_LOCK = threading.Lock() PENDING: dict[str, tuple[threading.Event, dict[str, Any]]] = {} PENDING_LOCK = threading.Lock() @@ -82,6 +97,8 @@ def verify_token_with_extension(token: str, timeout: float = 8.0) -> dict[str, A def call_extension(method: str, args: list[Any] | None = None, timeout: float = 15.0) -> dict[str, Any]: + if method not in ALLOWED_METHODS: + raise RuntimeError(f"method not allowed: {method}") req_id = str(uuid.uuid4()) event = threading.Event() slot: dict[str, Any] = {} @@ -247,7 +264,6 @@ def stdin_loop() -> None: def main() -> int: - STATE_DIR.mkdir(parents=True, exist_ok=True) threading.Thread(target=stdin_loop, name="fab-stdin", daemon=True).start() server = ThreadingHTTPServer((HOST, PORT), Handler) log(f"listening on http://{HOST}:{PORT}") diff --git a/tools/client.py b/tools/client.py index ea32df1..bd70f3c 100644 --- a/tools/client.py +++ b/tools/client.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Call Firefox Agent Bridge with a Bearer token from FAB_TOKEN or a file.""" +"""Call Bookmarks API with a Bearer token from FAB_TOKEN or a file.""" from __future__ import annotations import argparse @@ -62,7 +62,7 @@ def call( def main() -> int: - parser = argparse.ArgumentParser(description="Call Firefox Agent Bridge") + parser = argparse.ArgumentParser(description="Call Bookmarks API") parser.add_argument("method", help="e.g. bookmarks.search or meta.methods") parser.add_argument("args_json", nargs="?", default="[]", help="JSON array of arguments") parser.add_argument("--url", default=DEFAULT_BASE) diff --git a/tools/install-native-host.ps1 b/tools/install-native-host.ps1 index 88891e2..a3432e5 100644 --- a/tools/install-native-host.ps1 +++ b/tools/install-native-host.ps1 @@ -1,5 +1,5 @@ # Register the native messaging host for the current Windows user. -# Does not load the Firefox extension — see README / AGENTS.md. +# Does not load the add-on — see README / AGENTS.md. $ErrorActionPreference = "Stop" $Root = Split-Path -Parent $PSScriptRoot diff --git a/tools/register_client.py b/tools/register_client.py deleted file mode 100644 index 103bce0..0000000 --- a/tools/register_client.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -"""Secrets are issued in the add-on UI, not from this script.""" -from __future__ import annotations - -import sys - -print( - "Generate and revoke clients in Firefox: Bookmarks API toolbar → Manage clients.", - file=sys.stderr, -) -raise SystemExit(2)