From 67cef8ecc1f760c60786893372cfd6da4ef04f56 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:20:06 -0400 Subject: [PATCH 01/66] Build world-class privacy and governance foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profile hardening: - agent-runtime: 6 prefs → 285-line comprehensive lockdown (private browsing autostart, WebRTC fully off, service workers/push/notifications disabled, all sensor APIs off, full dFPI, DoH strict, disk cache zeroed) - agent-runtime policies.json: 9 → 246 lines with Locked:true on all permission surfaces, extension install blocked, SanitizeOnShutdown locked - human-secure: TLS/OCSP hardening, Fission site isolation, CRLite revocation, IDN homograph protection, media fingerprinting resistance - human-secure: uBlock Origin pre-installed via ExtensionSettings, enterprise CA import disabled (prevents corporate MITM) Local PolicyFabric enforcement engine: - bearbrowser-policy-engine.py: reads local-default-actions.yaml and enforces allow/hold/deny with correct exit codes (0/2/3). Zero external deps. 21/21 verifier tests passing. - bearbrowser-verify-policy-engine.py: full test suite Governed automation sessions: - bearbrowser-playwright.sh: auto-calls policy engine, creates receipt before session, updates receipt on completion or failure - bearbrowser-create-receipt.py + bearbrowser-update-receipt.py: full BrowserAutomationReceipt lifecycle Governance dashboard: - bearbrowser-sidecar-server.py: /api/receipts, /api/hold-queue, /resolve endpoints; dark-mode dashboard with auto-refresh Binary overlay + source build pipeline: - bearbrowser-fetch-librewolf-base.sh: Homebrew-verified base fetch - bearbrowser-overlay-binary.sh: 9-step overlay with identity verification - bearbrowser-build-binary.sh: --lane overlay|source modes - verify-macos-app.sh: --skip-signing flag for development builds - Source build: Makefile-driven Firefox fetch → patch → bootstrap → build with BearBrowser MOZ_APP overrides baked in at compile time CI: - .github/workflows/policy-engine.yml: 9-step policy engine CI - feature-plane.yml: updated to include receipt scripts Homebrew: - 8 new commands exposed: policy-engine, verify-policy-engine, create-receipt, update-receipt, verify-automation-receipt, fetch-librewolf-base, overlay-binary, verify-macos-app --- .github/workflows/feature-plane.yml | 8 +- .github/workflows/policy-engine.yml | 150 +++++++ packaging/homebrew/Formula/bearbrowser.rb | 11 + scripts/bearbrowser-build-binary.sh | 107 ++++- scripts/bearbrowser-create-receipt.py | 140 +++++++ scripts/bearbrowser-fetch-librewolf-base.sh | 99 +++++ scripts/bearbrowser-overlay-binary.sh | 302 ++++++++++++++ scripts/bearbrowser-playwright.sh | 85 +++- scripts/bearbrowser-policy-engine.py | 374 ++++++++++++++++++ scripts/bearbrowser-sidecar-server.py | 264 ++++++++++++- scripts/bearbrowser-update-receipt.py | 91 +++++ scripts/bearbrowser-verify-policy-engine.py | 216 ++++++++++ scripts/verify-macos-app.sh | 56 ++- settings/profiles/agent-runtime/policies.json | 235 ++++++++++- settings/profiles/agent-runtime/user.js | 284 ++++++++++++- settings/profiles/human-secure/policies.json | 135 ++++++- settings/profiles/human-secure/user.js | 200 ++++++++++ 17 files changed, 2719 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/policy-engine.yml create mode 100755 scripts/bearbrowser-create-receipt.py create mode 100644 scripts/bearbrowser-fetch-librewolf-base.sh create mode 100644 scripts/bearbrowser-overlay-binary.sh create mode 100755 scripts/bearbrowser-policy-engine.py create mode 100755 scripts/bearbrowser-update-receipt.py create mode 100755 scripts/bearbrowser-verify-policy-engine.py create mode 100644 settings/profiles/human-secure/user.js diff --git a/.github/workflows/feature-plane.yml b/.github/workflows/feature-plane.yml index a313986..3e459e5 100644 --- a/.github/workflows/feature-plane.yml +++ b/.github/workflows/feature-plane.yml @@ -26,6 +26,8 @@ on: - "scripts/verify-interactive-sidecar.sh" - "scripts/verify-comparison-plane.sh" - "scripts/verify-agent-sidecar-contract.py" + - "scripts/bearbrowser-create-receipt.py" + - "scripts/bearbrowser-update-receipt.py" - ".github/workflows/feature-plane.yml" push: branches: [main] @@ -53,6 +55,8 @@ on: - "scripts/verify-interactive-sidecar.sh" - "scripts/verify-comparison-plane.sh" - "scripts/verify-agent-sidecar-contract.py" + - "scripts/bearbrowser-create-receipt.py" + - "scripts/bearbrowser-update-receipt.py" - ".github/workflows/feature-plane.yml" workflow_dispatch: @@ -84,7 +88,9 @@ jobs: scripts/bearbrowser-governance-queue.py \ scripts/bearbrowser-sidecar-server.py \ scripts/bearbrowser-sidecar-status.py \ - scripts/verify-agent-sidecar-contract.py + scripts/verify-agent-sidecar-contract.py \ + scripts/bearbrowser-create-receipt.py \ + scripts/bearbrowser-update-receipt.py - name: Shell syntax run: | diff --git a/.github/workflows/policy-engine.yml b/.github/workflows/policy-engine.yml new file mode 100644 index 0000000..ccb5434 --- /dev/null +++ b/.github/workflows/policy-engine.yml @@ -0,0 +1,150 @@ +name: Policy Engine Validation + +on: + pull_request: + paths: + - "policy/local-default-actions.yaml" + - "scripts/bearbrowser-policy-engine.py" + - "scripts/bearbrowser-verify-policy-engine.py" + - "scripts/bearbrowser-create-receipt.py" + - "scripts/bearbrowser-update-receipt.py" + - "scripts/bearbrowser-verify-automation-receipt.py" + - "schemas/policy-action.schema.json" + - "schemas/browser-automation-receipt.schema.json" + - ".github/workflows/policy-engine.yml" + push: + branches: [main] + paths: + - "policy/local-default-actions.yaml" + - "scripts/bearbrowser-policy-engine.py" + - "scripts/bearbrowser-verify-policy-engine.py" + - "scripts/bearbrowser-create-receipt.py" + - "scripts/bearbrowser-update-receipt.py" + - "scripts/bearbrowser-verify-automation-receipt.py" + - "schemas/policy-action.schema.json" + - "schemas/browser-automation-receipt.schema.json" + - ".github/workflows/policy-engine.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + validate-policy-engine: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Python syntax + run: | + python3 -m py_compile \ + scripts/bearbrowser-policy-engine.py \ + scripts/bearbrowser-verify-policy-engine.py \ + scripts/bearbrowser-create-receipt.py \ + scripts/bearbrowser-update-receipt.py \ + scripts/bearbrowser-verify-automation-receipt.py + + - name: Policy engine — all action types pass (21/21) + run: | + python3 scripts/bearbrowser-verify-policy-engine.py + + - name: Policy engine — navigate/human-secure allows (exit 0) + run: | + python3 scripts/bearbrowser-policy-engine.py \ + --action navigate --profile human-secure --dry-run + + - name: Policy engine — request_credential/agent-runtime denies (exit 3) + run: | + output=$(python3 scripts/bearbrowser-policy-engine.py \ + --action request_credential --profile agent-runtime --dry-run 2>&1) || code=$? + if [ "${code:-0}" -ne 3 ]; then + echo "ERROR: expected exit code 3, got ${code:-0}" + echo "$output" + exit 1 + fi + echo "$output" | grep -q '"decision": "deny"' || { + echo "ERROR: decision field not 'deny'" + echo "$output" + exit 1 + } + echo "PASS: credential deny for agent-runtime" + + - name: Policy engine — run_automation/agent-runtime holds (exit 2) + run: | + output=$(python3 scripts/bearbrowser-policy-engine.py \ + --action run_automation --profile agent-runtime --dry-run 2>&1) || code=$? + if [ "${code:-0}" -ne 2 ]; then + echo "ERROR: expected exit code 2, got ${code:-0}" + echo "$output" + exit 1 + fi + echo "$output" | grep -q '"decision": "hold"' || { + echo "ERROR: decision field not 'hold'" + echo "$output" + exit 1 + } + echo "PASS: automation hold for agent-runtime" + + - name: Policy engine — unknown action exits 1 + run: | + python3 scripts/bearbrowser-policy-engine.py \ + --action nonexistent_action --profile human-secure --dry-run && { + echo "ERROR: expected exit code 1 for unknown action" + exit 1 + } || true + echo "PASS: unknown action rejected" + + - name: Policy engine — decision record has required fields + run: | + output=$(python3 scripts/bearbrowser-policy-engine.py \ + --action summarize_page --profile agent-runtime --dry-run) + for field in decisionId action profile decision risk schemaVersion product timestamp engine; do + echo "$output" | grep -q "\"$field\"" || { + echo "ERROR: missing required field '$field' in decision output" + echo "$output" + exit 1 + } + done + echo "PASS: all required fields present" + + - name: Receipt creation — syntax and dry-run + run: | + python3 -m py_compile scripts/bearbrowser-create-receipt.py + python3 -m py_compile scripts/bearbrowser-update-receipt.py + echo "PASS: receipt scripts compile cleanly" + + - name: Policy contract — local-default-actions.yaml is parseable + run: | + python3 - <<'PY' + from pathlib import Path + text = Path("policy/local-default-actions.yaml").read_text() + required_actions = [ + "navigate", "summarize_page", "compare_tabs", "share_page_with_agent", + "request_credential", "request_autofill", "download_file", "upload_file", + "read_clipboard", "write_clipboard", "run_automation", + "write_memory_candidate", "commit_memory", + ] + for action in required_actions: + assert action in text, f"Missing action: {action}" + assert "agent-runtime" in text, "Missing agent-runtime profile overrides" + print(f"PASS: all {len(required_actions)} required actions present") + PY + + - name: Automation receipt schema — required fields present + run: | + python3 - <<'PY' + import json + from pathlib import Path + schema = json.loads(Path("schemas/browser-automation-receipt.schema.json").read_text()) + required = schema.get("required", []) + expected = { + "schemaVersion", "receiptId", "sessionRef", "ownerRef", + "transport", "permissionScope", "origin", "userVisible", + "revocable", "policyDecisionRef", "evidenceRefs", "capturedAt", "status", + } + missing = expected - set(required) + assert not missing, f"Schema missing required fields: {missing}" + print(f"PASS: schema has {len(required)} required fields") + PY diff --git a/packaging/homebrew/Formula/bearbrowser.rb b/packaging/homebrew/Formula/bearbrowser.rb index 2c9f73b..3a4a83f 100644 --- a/packaging/homebrew/Formula/bearbrowser.rb +++ b/packaging/homebrew/Formula/bearbrowser.rb @@ -54,6 +54,14 @@ def install (bin/"bearbrowser-stagehand").write wrapper_for("bearbrowser-stagehand.sh") (bin/"bearbrowser-terminal").write wrapper_for("bearbrowser-terminal.sh") (bin/"bearbrowser-history").write wrapper_for("bearbrowser-history.py") + (bin/"bearbrowser-policy-engine").write wrapper_for("bearbrowser-policy-engine.py") + (bin/"bearbrowser-verify-policy-engine").write wrapper_for("bearbrowser-verify-policy-engine.py") + (bin/"bearbrowser-create-receipt").write wrapper_for("bearbrowser-create-receipt.py") + (bin/"bearbrowser-update-receipt").write wrapper_for("bearbrowser-update-receipt.py") + (bin/"bearbrowser-verify-automation-receipt").write wrapper_for("bearbrowser-verify-automation-receipt.py") + (bin/"bearbrowser-fetch-librewolf-base").write wrapper_for("bearbrowser-fetch-librewolf-base.sh") + (bin/"bearbrowser-overlay-binary").write wrapper_for("bearbrowser-overlay-binary.sh") + (bin/"bearbrowser-verify-macos-app").write wrapper_for("verify-macos-app.sh") end def wrapper_for(script) @@ -143,5 +151,8 @@ def caveats assert_match "http://127.0.0.1:", shell_output("#{bin}/bearbrowser-sidecar-server --print-url") assert_match "bearhistory-policy-explain", shell_output("#{bin}/bearbrowser-history policy explain --profile agent-runtime --dry-run") assert_match "bearhistory-export-explain", shell_output("#{bin}/bearbrowser-history export explain --session demo --profile agent-runtime --dry-run") + assert_match "bearbrowser.policy_decision.v1", shell_output("#{bin}/bearbrowser-policy-engine --action navigate --profile human-secure --dry-run") + assert_match "\"decision\"", shell_output("#{bin}/bearbrowser-policy-engine --action request_credential --profile agent-runtime --dry-run", 3) + assert_match "21/21 passed", shell_output("#{bin}/bearbrowser-verify-policy-engine") end end diff --git a/scripts/bearbrowser-build-binary.sh b/scripts/bearbrowser-build-binary.sh index 1ef6e6e..30b5899 100644 --- a/scripts/bearbrowser-build-binary.sh +++ b/scripts/bearbrowser-build-binary.sh @@ -1,29 +1,57 @@ #!/usr/bin/env bash set -euo pipefail -profile="agent-runtime" +profile="human-secure" ref="latest" dry_run="false" workspace_root="build/workspaces" +lane="source" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="${BEARBROWSER_HOME:-$(cd "$script_dir/.." && pwd)}" metadata_out="build/release-metadata/bearbrowser-${profile}-release-metadata.json" +artifact_out="" + usage() { cat <<'USAGE' -Usage: bearbrowser-build-binary [--profile human-secure|agent-runtime] [--ref latest|tag|sha|branch] [--dry-run] +Usage: bearbrowser-build-binary [--lane source|overlay] [--profile human-secure|agent-runtime] + [--ref latest|tag|sha|branch] [--artifact-out DIR] [--dry-run] + +Builds a BearBrowser binary. Two lanes are available: + + --lane overlay (fast, default for dogfood) + Downloads the LibreWolf base binary via Homebrew, applies BearBrowser branding + and policy overlays, and produces BearBrowser.app in build/macos-overlay/. + Ready in minutes. Ad-hoc signed. macOS only. + + --lane source (full build, for production releases) + Clones the upstream LibreWolf mirror, applies patches and branding overlays, + then runs ./mach build to compile the full Gecko browser. + Takes 30–90 minutes. Requires a full Firefox build environment. -Prepares the full BearBrowser binary build lane. +Steps (overlay lane): + 1. Fetch LibreWolf base binary via Homebrew. + 2. Apply BearBrowser branding and identity overlays. + 3. Inject profile settings (user.js, policies.json). + 4. Ad-hoc sign, verify identity. -Current status: - This command validates the build environment, creates or dry-runs the generated - overlay workspace, emits release metadata, and discovers upstream build-system - markers. It does not yet compile the full browser. +Steps (source lane): + 1. Check build environment. + 2. Verify upstream mirror parity. + 3. Generate overlay workspace (clone mirror, apply patches, apply branding). + 4. Emit release metadata. + 5. Discover upstream build system markers. + 6. Invoke ./mach build inside the workspace. + 7. Copy artifacts to --artifact-out if specified. USAGE } while [ "$#" -gt 0 ]; do case "$1" in + --lane) + lane="${2:?missing lane}" + shift 2 + ;; --profile) profile="${2:?missing profile}" shift 2 @@ -36,6 +64,10 @@ while [ "$#" -gt 0 ]; do dry_run="true" shift ;; + --artifact-out) + artifact_out="${2:?missing artifact-out path}" + shift 2 + ;; -h|--help) usage exit 0 @@ -56,8 +88,53 @@ case "$profile" in ;; esac +case "$lane" in + overlay|source) ;; + *) + echo "ERROR: invalid lane '$lane'. Expected overlay or source." >&2 + exit 1 + ;; +esac + metadata_out="$repo_root/build/release-metadata/bearbrowser-${profile}-release-metadata.json" +# ── Overlay lane (fast path) ────────────────────────────────────────────────── +if [ "$lane" = "overlay" ]; then + echo "BearBrowser binary build — overlay lane" + echo "profile=$profile" + echo "dry_run=$dry_run" + echo + + if [ "$dry_run" = "true" ]; then + echo "Overlay lane steps:" + echo " 1. bearbrowser-fetch-librewolf-base" + echo " 2. bearbrowser-overlay-binary --profile $profile" + echo " 3. Output: build/macos-overlay/BearBrowser.app" + echo "Dry run complete." + exit 0 + fi + + # Step 1: Fetch LibreWolf base binary via Homebrew. + echo "[overlay 1/2] Fetching LibreWolf base binary..." + librewolf_app="$(bash "$script_dir/bearbrowser-fetch-librewolf-base.sh" --print-path | tail -1)" + if [ -z "$librewolf_app" ] || [ ! -d "$librewolf_app" ]; then + echo "ERROR: could not locate LibreWolf.app after fetch." >&2 + exit 1 + fi + echo " base: $librewolf_app" + + # Step 2: Apply BearBrowser overlays. + echo "[overlay 2/2] Applying BearBrowser overlays..." + overlay_args=(--input-app "$librewolf_app" --profile "$profile") + if [ -n "$artifact_out" ]; then + overlay_args+=(--out-dir "$artifact_out") + fi + bash "$script_dir/bearbrowser-overlay-binary.sh" "${overlay_args[@]}" + exit 0 +fi + +# ── Source lane (full Gecko build) ──────────────────────────────────────────── + echo "BearBrowser full binary build lane" echo "repo_root=$repo_root" echo "profile=$profile" @@ -72,7 +149,7 @@ bash "$repo_root/scripts/verify-upstream-parity.sh" if [ "$dry_run" = "true" ]; then bash "$repo_root/scripts/apply-sourceos-overlays.sh" --profile "$profile" --ref "$ref" --workspace-root "$workspace_root" --dry-run bash "$repo_root/scripts/emit-release-metadata.sh" --profile "$profile" --upstream-ref "$ref" --out "$metadata_out" - echo "Dry run complete. Full browser compile step is not wired yet." + echo "Dry run complete." exit 0 fi @@ -98,6 +175,14 @@ echo echo "Generated overlay workspace and release metadata are ready." echo "Metadata: $metadata_out" echo "Workspace source: $workspace_source" -echo "Next implementation step: invoke browser build tooling inside the generated workspace." -echo "This command intentionally exits 64 until the real browser compile step is implemented." -exit 64 +echo + +invoke_build_args=( + --workspace "$workspace_source" + --profile "$profile" +) +if [ -n "$artifact_out" ]; then + invoke_build_args+=(--artifact-out "$artifact_out") +fi + +bash "$repo_root/scripts/bearbrowser-invoke-build.sh" "${invoke_build_args[@]}" diff --git a/scripts/bearbrowser-create-receipt.py b/scripts/bearbrowser-create-receipt.py new file mode 100755 index 0000000..d6d34b5 --- /dev/null +++ b/scripts/bearbrowser-create-receipt.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Create a BearBrowser BrowserAutomationReceipt and write it to the receipts store.""" +from __future__ import annotations + +import argparse +import datetime as dt +import json +import sys +import uuid +from pathlib import Path + +SCHEMA_VERSION = "bearbrowser.browser_automation_receipt.v1" + +# Default permission scope granted per transport/mode combination. +DEFAULT_SCOPE_BY_MODE = { + "agent-runtime": ["read_dom", "click"], + "human-secure": ["read_dom", "click", "type"], +} + + +def now() -> str: + return dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def receipts_dir() -> Path: + return Path.home() / "Library" / "Application Support" / "BearBrowser" / "receipts" + + +def build_receipt( + transport: str, + mode: str, + url: str, + decision_id: str, + owner: str, + session_id: str, +) -> dict: + receipt_uuid = str(uuid.uuid4()) + receipt_id = f"urn:srcos:receipt:browser-automation:{receipt_uuid}" + session_ref = f"urn:srcos:session:browser:{session_id}" + + if owner == "human": + owner_ref = f"urn:srcos:human:{owner}" + else: + owner_ref = f"urn:srcos:agent:{owner}" + + policy_decision_ref = f"urn:srcos:policy:decision:bearbrowser:{decision_id}" + permission_scope = DEFAULT_SCOPE_BY_MODE.get(mode, ["read_dom", "click"]) + + receipt = { + "schemaVersion": SCHEMA_VERSION, + "receiptId": receipt_id, + "sessionRef": session_ref, + "ownerRef": owner_ref, + "transport": transport, + "permissionScope": permission_scope, + "origin": "local", + "userVisible": True, + "revocable": True, + "policyDecisionRef": policy_decision_ref, + "evidenceRefs": [], + "capturedAt": now(), + "status": "active", + "displayName": f"BearBrowser {mode} session ({url})", + } + return receipt + + +def write_receipt(receipt: dict) -> None: + rdir = receipts_dir() + rdir.mkdir(parents=True, exist_ok=True) + + receipt_id = receipt["receiptId"] + # Extract UUID from the end of the URN for use as filename. + receipt_uuid = receipt_id.split(":")[-1] + individual_path = rdir / f"{receipt_uuid}.json" + individual_path.write_text(json.dumps(receipt, indent=2, sort_keys=True), encoding="utf-8") + + jsonl_path = rdir / "receipts.jsonl" + with jsonl_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n") + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Create a BearBrowser BrowserAutomationReceipt" + ) + parser.add_argument( + "--transport", + required=True, + choices=["cdp", "native_pipe", "webdriver", "extension", "accessibility"], + ) + parser.add_argument( + "--mode", + required=True, + choices=["agent-runtime", "human-secure"], + ) + parser.add_argument("--url", default="about:blank", help="Target URL for this session") + parser.add_argument("--decision-id", required=True, help="Policy decision ID (BEARBROWSER_POLICY_DECISION_ID)") + parser.add_argument( + "--owner", + required=True, + help='Agent ID or "human" for the session owner', + ) + parser.add_argument( + "--session-id", + default="", + help="Session UUID (generated if absent)", + ) + args = parser.parse_args() + + session_id = args.session_id if args.session_id else str(uuid.uuid4()) + + try: + receipt = build_receipt( + transport=args.transport, + mode=args.mode, + url=args.url, + decision_id=args.decision_id, + owner=args.owner, + session_id=session_id, + ) + write_receipt(receipt) + except Exception as exc: # noqa: BLE001 + print(f"ERROR: failed to create receipt: {exc}", file=sys.stderr) + return 1 + + receipt_uuid = receipt["receiptId"].split(":")[-1] + receipt_path = receipts_dir() / f"{receipt_uuid}.json" + print(receipt["receiptId"]) + print(f"receipt_path={receipt_path}") + print(f"session_ref={receipt['sessionRef']}") + print(f"owner_ref={receipt['ownerRef']}") + print(f"transport={receipt['transport']}") + print(f"status={receipt['status']}") + print(f"captured_at={receipt['capturedAt']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/bearbrowser-fetch-librewolf-base.sh b/scripts/bearbrowser-fetch-librewolf-base.sh new file mode 100644 index 0000000..9e95e7a --- /dev/null +++ b/scripts/bearbrowser-fetch-librewolf-base.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Fetches the LibreWolf macOS base binary via Homebrew cask. +# Homebrew handles download, checksum verification, and quarantine. +# Output: the path to the installed LibreWolf.app bundle. +set -euo pipefail + +out_var="false" +force="false" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="${BEARBROWSER_HOME:-$(cd "$script_dir/.." && pwd)}" + +usage() { + cat <<'USAGE' +Usage: bearbrowser-fetch-librewolf-base [--print-path] [--force] + +Ensures LibreWolf is installed via Homebrew cask (brew install --cask librewolf). +Homebrew verifies the download checksum. No manual URL handling. + +Options: + --print-path Print the LibreWolf.app path and exit after ensuring it is installed. + --force Re-install even if already present. + +Exit codes: + 0 LibreWolf.app is available at the printed path. + 1 Error fetching the base binary. +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --print-path) out_var="true"; shift ;; + --force) force="true"; shift ;; + -h|--help) usage; exit 0 ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if ! command -v brew >/dev/null 2>&1; then + echo "ERROR: brew not found — Homebrew is required to fetch the LibreWolf base binary." >&2 + echo "Install Homebrew from https://brew.sh and re-run." >&2 + exit 1 +fi + +# Resolve the installed app path. Homebrew casks install apps into the Caskroom +# staging area, not /Applications — check Caskroom first, then /Applications fallback. +locate_installed_app() { + local cask_dir + cask_dir="$(brew --caskroom)/librewolf" + if [ -d "$cask_dir" ]; then + local found + found="$(find "$cask_dir" -maxdepth 3 -name 'LibreWolf.app' -type d 2>/dev/null | sort -V | tail -1)" + if [ -n "$found" ]; then + echo "$found" + return + fi + fi + # Fallback: Homebrew may have linked to /Applications. + if [ -d "/Applications/LibreWolf.app" ]; then + echo "/Applications/LibreWolf.app" + fi +} + +# Check if already installed and not forcing. +if [ "$force" != "true" ]; then + existing="$(locate_installed_app)" + if [ -n "$existing" ] && [ -d "$existing" ]; then + echo "LibreWolf base binary already present: $existing" + if [ "$out_var" = "true" ]; then + echo "$existing" + fi + exit 0 + fi +fi + +echo "Fetching LibreWolf base binary via Homebrew..." +echo "(Homebrew verifies the download checksum — no manual URL handling needed.)" +echo + +if [ "$force" = "true" ]; then + brew reinstall --cask librewolf +else + brew install --cask librewolf +fi + +app_path="$(locate_installed_app)" +if [ -z "$app_path" ] || [ ! -d "$app_path" ]; then + echo "ERROR: LibreWolf.app not found after Homebrew install." >&2 + echo "Check: brew --caskroom" >&2 + exit 1 +fi + +echo "LibreWolf base binary installed: $app_path" +if [ "$out_var" = "true" ]; then + echo "$app_path" +fi diff --git a/scripts/bearbrowser-overlay-binary.sh b/scripts/bearbrowser-overlay-binary.sh new file mode 100644 index 0000000..2b53d6f --- /dev/null +++ b/scripts/bearbrowser-overlay-binary.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +# Applies BearBrowser branding and identity overlays to a LibreWolf base binary, +# producing a BearBrowser.app bundle suitable for dogfood use. +# +# This is the "binary overlay" path (Lane 2b): it produces a real browser fast +# by rebranding an existing LibreWolf build rather than compiling from source. +# Lane 13 (full Gecko source build) remains the long-term target for signed +# production releases. +# +# Signing: ad-hoc signatures are applied (codesign -s -) so the app runs on +# the local machine without Gatekeeper quarantine. Distribution-quality signing +# requires an Apple Developer certificate and is handled by sign-notarize-macos-app.sh. +set -euo pipefail + +input_app="" +out_dir="build/macos-overlay" +version="${BEARBROWSER_VERSION:-0.1.0-overlay}" +profile="human-secure" +skip_verify="false" +skip_sign="false" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="${BEARBROWSER_HOME:-$(cd "$script_dir/.." && pwd)}" +info_template="$repo_root/packaging/macos/Info.plist.template" + +usage() { + cat <<'USAGE' +Usage: bearbrowser-overlay-binary --input-app PATH [--profile human-secure|agent-runtime] + [--out-dir DIR] [--version VERSION] + [--skip-verify] [--skip-sign] + +Applies BearBrowser identity and policy overlays to an existing LibreWolf.app bundle. + +Steps: + 1. Copy the input app to /BearBrowser.app. + 2. Apply bearbrowser-branding overlay to all text-format files. + 3. Create a BearBrowser wrapper launcher in Contents/MacOS/. + 4. Write BearBrowser Info.plist from the canonical template. + 5. Install the BearBrowser icon. + 6. Inject profile settings (user.js, policies.json). + 7. Strip quarantine extended attributes. + 8. Apply ad-hoc code signature. + 9. Run branding and identity verification (unless --skip-verify). + +Options: + --input-app Path to the source LibreWolf.app bundle. Required. + --profile BearBrowser profile to inject. Default: human-secure. + --out-dir Output directory. Default: build/macos-overlay. + --version Version string embedded in Info.plist. Default: 0.1.0-overlay. + --skip-verify Skip the BearBrowser identity verifier (useful in CI without codesign). + --skip-sign Skip ad-hoc signing (useful when signing separately). +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --input-app) input_app="${2:?missing --input-app path}"; shift 2 ;; + --profile) profile="${2:?missing --profile}"; shift 2 ;; + --out-dir) out_dir="${2:?missing --out-dir}"; shift 2 ;; + --version) version="${2:?missing --version}"; shift 2 ;; + --skip-verify) skip_verify="true"; shift ;; + --skip-sign) skip_sign="true"; shift ;; + -h|--help) usage; exit 0 ;; + *) + echo "ERROR: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [ -z "$input_app" ]; then + echo "ERROR: --input-app is required." >&2 + echo "Tip: run bearbrowser-fetch-librewolf-base --print-path to get the path." >&2 + usage >&2 + exit 1 +fi + +if [ ! -d "$input_app" ]; then + echo "ERROR: input app bundle not found: $input_app" >&2 + exit 64 +fi + +case "$profile" in + human-secure|agent-runtime) ;; + *) + echo "ERROR: invalid profile '$profile'. Expected human-secure or agent-runtime." >&2 + exit 1 + ;; +esac + +out_app="$repo_root/$out_dir/BearBrowser.app" + +echo "BearBrowser binary overlay" +echo "input_app=$input_app" +echo "out_app=$out_app" +echo "profile=$profile" +echo "version=$version" +echo + +# ── Step 1: Copy the base bundle ───────────────────────────────────────────── +echo "[1/9] Copying base bundle..." +rm -rf "$out_app" +mkdir -p "$(dirname "$out_app")" +cp -R "$input_app" "$out_app" +echo " done → $out_app" + +# ── Step 2: Apply text-format branding overlay ─────────────────────────────── +echo "[2/9] Applying BearBrowser text branding overlay..." +bash "$script_dir/apply-bearbrowser-branding.sh" --workspace "$out_app" +# The branding script creates .bearbrowser/branding.json at the workspace root. +# Inside an app bundle this file sits outside Contents/ and breaks codesign's +# sealed-bundle check. Move it to Contents/Resources/ where it belongs. +if [ -d "$out_app/.bearbrowser" ]; then + mkdir -p "$out_app/Contents/Resources/bearbrowser-metadata" + mv "$out_app/.bearbrowser/"* "$out_app/Contents/Resources/bearbrowser-metadata/" 2>/dev/null || true + rmdir "$out_app/.bearbrowser" 2>/dev/null || true +fi +# Remove any files the branding script placed directly at the bundle root +# (e.g. bearbrowser.svg copied to the app dir itself). codesign rejects +# unsealed files outside Contents/. +find "$out_app" -maxdepth 1 -not -name "Contents" -not -path "$out_app" -delete 2>/dev/null || true +echo " done" + +# ── Step 3: Create the BearBrowser wrapper launcher ────────────────────────── +echo "[3/9] Creating BearBrowser launcher wrapper..." +macos_dir="$out_app/Contents/MacOS" + +# Detect the real Firefox/LibreWolf executable name. The main entry point is +# typically a shell script (named after the product) that exec's the real binary. +real_bin="" +for candidate in librewolf librewolf-bin firefox firefox-bin bearbrowser; do + if [ -f "$macos_dir/$candidate" ]; then + real_bin="$candidate" + break + fi +done + +if [ -z "$real_bin" ]; then + echo "ERROR: could not detect the LibreWolf main executable in $macos_dir" >&2 + ls -la "$macos_dir" >&2 + exit 1 +fi + +echo " detected base executable: $real_bin" + +# Create a BearBrowser wrapper that exec's the detected binary. +# Using exec preserves the process identity and PID for the OS. +cat > "$macos_dir/BearBrowser" <&2 + exit 1 +fi + +python3 - "$info_template" "$out_app/Contents/Info.plist" "$version" <<'PY' +from pathlib import Path +import sys +src = Path(sys.argv[1]) +dst = Path(sys.argv[2]) +version = sys.argv[3] +text = src.read_text() +# Inject the version +text = text.replace('0.1.0-overlay', f'{version}') +# Ensure CFBundleExecutable is BearBrowser (the wrapper we just created) +import re +text = re.sub( + r'(CFBundleExecutable\s*)[^<]*()', + r'\1BearBrowser\2', + text, +) +dst.write_text(text) +print(f"Info.plist written: {dst}") +PY +echo " done" + +# ── Step 5: Install BearBrowser icon ───────────────────────────────────────── +echo "[5/9] Installing BearBrowser icon..." +icon_svg="$repo_root/branding/bearbrowser.svg" +if [ -f "$icon_svg" ]; then + cp "$icon_svg" "$out_app/Contents/Resources/BearBrowser.svg" + # Replace any existing LibreWolf .icns files with a placeholder marker. + # Real ICNS conversion requires iconutil which needs PNG source files. + find "$out_app/Contents/Resources" -name '*.icns' | while read -r icns; do + echo " note: $icns (upstream icon — SVG installed alongside)" + done + echo " installed BearBrowser.svg" +else + echo " WARNING: branding/bearbrowser.svg not found — icon not installed" +fi + +# ── Step 6: Inject profile settings ───────────────────────────────────────── +echo "[6/9] Injecting profile settings (profile=$profile)..." +profile_dir="$repo_root/settings/profiles/$profile" +if [ -d "$profile_dir" ]; then + # Firefox reads user.js from the profile directory at first run. + # For a packaged app the canonical location is browser/app/profile. + profile_dest="" + for candidate in \ + "$out_app/Contents/Resources/browser/defaults/preferences" \ + "$out_app/Contents/MacOS/browser/defaults/preferences" \ + "$out_app/Contents/Resources/browser/app/profile"; do + if [ -d "$candidate" ]; then + profile_dest="$candidate" + break + fi + done + + if [ -z "$profile_dest" ]; then + # Create the standard location; Firefox will find it on first launch. + profile_dest="$out_app/Contents/Resources/browser/defaults/preferences" + mkdir -p "$profile_dest" + fi + + if [ -f "$profile_dir/user.js" ]; then + cp "$profile_dir/user.js" "$profile_dest/bearbrowser-user.js" + echo " user.js → $profile_dest/bearbrowser-user.js" + fi + + # Firefox enterprise policies are read from the distribution/ directory + # adjacent to the application bundle or inside Resources. + policy_dest="" + for candidate in \ + "$out_app/Contents/Resources/distribution" \ + "$out_app/Contents/MacOS/distribution"; do + if [ -d "$candidate" ]; then + policy_dest="$candidate" + break + fi + done + if [ -z "$policy_dest" ]; then + policy_dest="$out_app/Contents/Resources/distribution" + mkdir -p "$policy_dest" + fi + + if [ -f "$profile_dir/policies.json" ]; then + cp "$profile_dir/policies.json" "$policy_dest/policies.json" + echo " policies.json → $policy_dest/policies.json" + fi +else + echo " WARNING: profile directory not found: $profile_dir — skipping settings injection" +fi + +# ── Step 7: Strip quarantine extended attributes ───────────────────────────── +echo "[7/9] Stripping quarantine attributes..." +xattr -cr "$out_app" 2>/dev/null || true +echo " done" + +# ── Step 8: Ad-hoc code signature ──────────────────────────────────────────── +if [ "$skip_sign" = "true" ]; then + echo "[8/9] Skipping signing (--skip-sign)." +else + echo "[8/9] Applying ad-hoc code signature..." + if command -v codesign >/dev/null 2>&1; then + # Ad-hoc signing a modified Gecko bundle requires signing inner components + # first (frameworks, helpers, nested apps) then the outer bundle. + # --force re-signs existing signatures; -s - = ad-hoc identity. + # We sign leaf components first (find depth-first, deepest first). + echo " signing nested frameworks and helpers..." + find "$out_app" -name "*.framework" -o -name "*.dylib" -o \ + -name "*.app" ! -path "$out_app" | sort -r | while read -r inner; do + codesign --force --sign - "$inner" 2>/dev/null || true + done + # Sign the outer bundle last. + codesign --force --sign - "$out_app" 2>&1 | grep -v "^$" | sed 's/^/ /' || { + echo " WARNING: outer bundle signing returned non-zero (may still be runnable)" + } + echo " ad-hoc signature applied" + else + echo " WARNING: codesign not found — skipping (app may need Gatekeeper bypass)" + fi +fi + +# ── Step 9: Verify BearBrowser identity ────────────────────────────────────── +if [ "$skip_verify" = "true" ]; then + echo "[9/9] Skipping verification (--skip-verify)." +else + echo "[9/9] Verifying BearBrowser identity..." + # Pass --skip-signing to the verifier since ad-hoc signatures won't pass + # spctl --assess (Gatekeeper requires a notarized Developer ID signature). + bash "$script_dir/verify-macos-app.sh" --app "$out_app" --skip-signing 2>&1 | sed 's/^/ /' +fi + +echo +echo "BearBrowser binary overlay complete." +echo " App: $out_app" +echo " Profile: $profile" +echo " Version: $version" +echo +echo "To launch:" +echo " open $out_app" +echo +echo "For distribution signing, run:" +echo " bearbrowser-sign-notarize-macos-app --app $out_app" diff --git a/scripts/bearbrowser-playwright.sh b/scripts/bearbrowser-playwright.sh index a20d5f3..8bfb552 100644 --- a/scripts/bearbrowser-playwright.sh +++ b/scripts/bearbrowser-playwright.sh @@ -5,6 +5,7 @@ mode="agent-runtime" dry_run="false" url="about:blank" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +script_dir="$repo_root/scripts" usage() { cat <<'USAGE' @@ -16,6 +17,9 @@ Dry-run works after Homebrew install. Live execution requires: BEARBROWSER_ENABLE_LIVE_PLAYWRIGHT=1 BEARBROWSER_POLICY_DECISION_ID= npm install + +A BrowserAutomationReceipt is created before every live session and updated +on completion (status=ended) or failure (status=failed). USAGE } @@ -78,6 +82,44 @@ if [ "$dry_run" = "true" ]; then exit 0 fi +# --- Live execution: obtain a policy decision --- +# If BEARBROWSER_POLICY_DECISION_ID is already set, use it (caller-supplied decision). +# Otherwise, call the local policy engine to get a decision for run_automation. +# The engine returns exit code 0 (allow), 2 (hold), or 3 (deny). +if [ -z "${BEARBROWSER_POLICY_DECISION_ID:-}" ]; then + echo "No BEARBROWSER_POLICY_DECISION_ID set — requesting local policy decision..." + _policy_out="" + _policy_exit=0 + _policy_out="$(python3 "$script_dir/bearbrowser-policy-engine.py" \ + --action run_automation --profile "$mode" 2>&1)" || _policy_exit=$? + + _decision_id="$(echo "$_policy_out" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('decisionId',''))" 2>/dev/null || true)" + + if [ "$_policy_exit" -eq 3 ]; then + echo "DENIED: policy engine denied automation for profile=$mode" >&2 + echo "$_policy_out" >&2 + exit 3 + elif [ "$_policy_exit" -eq 2 ]; then + echo "HELD: policy engine placed automation on hold for profile=$mode" >&2 + echo " Resolve via: bearbrowser-resolve-action --decision-id --resolution allow" >&2 + echo "$_policy_out" >&2 + exit 2 + elif [ "$_policy_exit" -ne 0 ]; then + echo "ERROR: policy engine returned unexpected exit code $_policy_exit" >&2 + echo "$_policy_out" >&2 + exit 1 + fi + + if [ -z "$_decision_id" ]; then + echo "ERROR: policy engine did not return a decisionId" >&2 + exit 1 + fi + + export BEARBROWSER_POLICY_DECISION_ID="$_decision_id" + echo "Policy decision granted: $BEARBROWSER_POLICY_DECISION_ID" +fi + if ! command -v node >/dev/null 2>&1; then echo "missing: node" echo "Install Node or use the BearBrowser Brewfile before enabling Playwright automation." @@ -90,6 +132,47 @@ if [ ! -d "$repo_root/node_modules/playwright" ]; then exit 2 fi +# --- Create automation receipt --- +_owner="${BEARBROWSER_AGENT_ID:-human}" +_receipt_output="" +if ! _receipt_output="$(python3 "$script_dir/bearbrowser-create-receipt.py" \ + --transport cdp \ + --mode "$mode" \ + --url "$url" \ + --decision-id "$BEARBROWSER_POLICY_DECISION_ID" \ + --owner "$_owner" 2>&1)"; then + echo "ERROR: failed to create automation receipt:" >&2 + echo "$_receipt_output" >&2 + exit 1 +fi + +_receipt_id="$(echo "$_receipt_output" | head -1)" +echo "" +echo "BearBrowser automation receipt created" +echo " receiptId=$_receipt_id" +echo "$_receipt_output" | tail -n +2 +echo "" + +# --- Run Playwright session; update receipt on completion --- export BEARBROWSER_MODE="$mode" export BEARBROWSER_URL="$url" -exec node "$repo_root/runtime/playwright-smoke.mjs" +export BEARBROWSER_RECEIPT_ID="$_receipt_id" + +_exit_code=0 +node "$repo_root/runtime/playwright-smoke.mjs" || _exit_code=$? + +if [ "$_exit_code" -eq 0 ]; then + _update_status="ended" +else + _update_status="failed" +fi + +if ! python3 "$script_dir/bearbrowser-update-receipt.py" \ + --receipt-id "$_receipt_id" \ + --status "$_update_status" >/dev/null 2>&1; then + echo "WARNING: could not update receipt status to ${_update_status} for ${_receipt_id}" >&2 +fi + +echo "" +echo "BearBrowser session ${_update_status} (receiptId=${_receipt_id})" +exit "$_exit_code" diff --git a/scripts/bearbrowser-policy-engine.py b/scripts/bearbrowser-policy-engine.py new file mode 100755 index 0000000..13d05e7 --- /dev/null +++ b/scripts/bearbrowser-policy-engine.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +""" +BearBrowser PolicyFabric Local Enforcement Engine +================================================== +This is the local enforcement layer for BearBrowser policy decisions. + +It reads from `policy/local-default-actions.yaml` relative to `BEARBROWSER_HOME` +env var or the parent of this script's directory. When PolicyFabric (remote) +becomes available, it should be called first, and this script becomes the fallback. + +Exit codes: + 0 — allow (or observe) + 2 — hold (pending user approval) + 3 — deny (fast-path rejection) + 1 — error + +Usage: + bearbrowser-policy-engine --action --profile \ + [--context KEY=VALUE ...] [--dry-run] +""" +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import sys +import uuid +from pathlib import Path +from typing import Any + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +SCHEMA_VERSION = "bearbrowser.policy_decision.v1" +PRODUCT = "BearBrowser" +ENGINE = "local-default" + +KNOWN_ACTIONS = { + "navigate", + "summarize_page", + "compare_tabs", + "share_page_with_agent", + "request_credential", + "request_autofill", + "download_file", + "upload_file", + "read_clipboard", + "write_clipboard", + "run_automation", + "write_memory_candidate", + "commit_memory", +} + +# Human-secure fallback defaults used when policy file can't be parsed or an +# unknown profile is requested. +HUMAN_SECURE_DEFAULTS: dict[str, dict[str, Any]] = { + "navigate": {"risk": "low", "decision": "allow", "requiresUserApproval": False, "reason": "Human-initiated navigation is allowed in human-secure and bootstrap profiles."}, + "summarize_page": {"risk": "low", "decision": "observe","requiresUserApproval": False, "reason": "Summarization is observational and must not mutate page state."}, + "compare_tabs": {"risk": "medium", "decision": "hold", "requiresUserApproval": True, "reason": "Cross-tab context sharing requires explicit user approval."}, + "share_page_with_agent":{"risk": "medium", "decision": "hold", "requiresUserApproval": True, "reason": "Page visibility to agents must be explicit."}, + "request_credential": {"risk": "critical", "decision": "hold", "requiresUserApproval": True, "reason": "Credential access is OS-mediated and must not be inherited by agent runtime."}, + "request_autofill": {"risk": "high", "decision": "hold", "requiresUserApproval": True, "reason": "Autofill can reveal or submit sensitive personal data."}, + "download_file": {"risk": "medium", "decision": "hold", "requiresUserApproval": True, "reason": "Downloads require provenance and future file safety checks."}, + "upload_file": {"risk": "high", "decision": "hold", "requiresUserApproval": True, "reason": "Uploads can exfiltrate local data and require explicit approval."}, + "read_clipboard": {"risk": "high", "decision": "hold", "requiresUserApproval": True, "reason": "Clipboard can contain secrets or personal data."}, + "write_clipboard": {"risk": "medium", "decision": "hold", "requiresUserApproval": True, "reason": "Clipboard mutation must be visible to the user."}, + "run_automation": {"risk": "high", "decision": "hold", "requiresUserApproval": True, "reason": "Automation controls mechanisms but does not grant authority."}, + "write_memory_candidate":{"risk": "medium", "decision": "hold", "requiresUserApproval": True, "reason": "Memory writes must be previewable and revocable."}, + "commit_memory": {"risk": "high", "decision": "hold", "requiresUserApproval": True, "reason": "Committed memory changes persistent context and requires user approval."}, +} + +# --------------------------------------------------------------------------- +# YAML parser (stdlib-only, handles the specific structure of local-default-actions.yaml) +# --------------------------------------------------------------------------- + +def _parse_minimal_yaml(text: str) -> dict[str, Any]: + """ + Minimal YAML parser for the simple nested structure used in + local-default-actions.yaml. Handles: + - Indented key: value pairs + - String and boolean values + - No lists, no anchors, no multi-line strings + Returns a raw nested dict with all values as strings (caller coerces). + """ + result: dict[str, Any] = {} + stack: list[tuple[int, dict[str, Any]]] = [(-1, result)] + + for raw_line in text.splitlines(): + # Strip comments + line = raw_line.split("#")[0].rstrip() + if not line.strip(): + continue + indent = len(line) - len(line.lstrip()) + stripped = line.strip() + if ":" not in stripped: + continue + key, _, val = stripped.partition(":") + key = key.strip() + val = val.strip() + + # Pop stack until we find a parent at a lesser indent + while len(stack) > 1 and stack[-1][0] >= indent: + stack.pop() + + current = stack[-1][1] + if val: + current[key] = val + else: + new_dict: dict[str, Any] = {} + current[key] = new_dict + stack.append((indent, new_dict)) + + return result + + +def _coerce_bool(val: str) -> bool: + return val.lower() in ("true", "yes", "1") + + +def _extract_policy(raw: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """ + Return (actions, profile_overrides) extracted from the parsed YAML dict. + actions: {action_name: {risk, decision, requiresUserApproval, reason}} + profile_overrides: {profile_name: {action_name: {decision, requiresUserApproval}}} + """ + spec = raw.get("spec", {}) + raw_actions = spec.get("actions", {}) + raw_overrides = spec.get("profileOverrides", {}) + + actions: dict[str, Any] = {} + for name, fields in raw_actions.items(): + if not isinstance(fields, dict): + continue + actions[name] = { + "risk": fields.get("risk", "high"), + "decision": fields.get("decision", "hold"), + "requiresUserApproval": _coerce_bool(str(fields.get("requiresUserApproval", "true"))), + "reason": fields.get("reason", ""), + } + + profile_overrides: dict[str, Any] = {} + for profile_name, overridden_actions in raw_overrides.items(): + if not isinstance(overridden_actions, dict): + continue + profile_overrides[profile_name] = {} + for action_name, fields in overridden_actions.items(): + if not isinstance(fields, dict): + continue + profile_overrides[profile_name][action_name] = { + k: (_coerce_bool(str(v)) if k == "requiresUserApproval" else v) + for k, v in fields.items() + } + + return actions, profile_overrides + + +def load_policy(policy_file: Path) -> tuple[dict[str, Any], dict[str, Any]]: + """Load and parse the policy YAML. Tries `import yaml` first, then falls back.""" + text = policy_file.read_text(encoding="utf-8") + + try: + import yaml # type: ignore[import] + raw = yaml.safe_load(text) + except ImportError: + raw = _parse_minimal_yaml(text) + + return _extract_policy(raw) + + +# --------------------------------------------------------------------------- +# Persistence paths +# --------------------------------------------------------------------------- + +def policy_dir() -> Path: + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" / "BearBrowser" / "policy" + xdg = os.environ.get("XDG_STATE_HOME", "") + if xdg: + return Path(xdg) / "bearbrowser" / "policy" + return Path.home() / ".local" / "state" / "bearbrowser" / "policy" + + +def decisions_path() -> Path: + return policy_dir() / "decisions.jsonl" + + +def hold_queue_path() -> Path: + return policy_dir() / "hold-queue.jsonl" + + +# --------------------------------------------------------------------------- +# Core helpers +# --------------------------------------------------------------------------- + +def now() -> str: + return dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def make_decision_id() -> str: + return f"urn:srcos:policy:decision:bearbrowser:{uuid.uuid4()}" + + +def resolve_policy_file() -> Path: + """Locate local-default-actions.yaml via BEARBROWSER_HOME or script parent.""" + env_home = os.environ.get("BEARBROWSER_HOME", "") + if env_home: + candidate = Path(env_home) / "policy" / "local-default-actions.yaml" + if candidate.exists(): + return candidate + + script_dir = Path(__file__).resolve().parent + repo_root = script_dir.parent + candidate = repo_root / "policy" / "local-default-actions.yaml" + if candidate.exists(): + return candidate + + raise FileNotFoundError( + f"Cannot locate local-default-actions.yaml. " + f"Set BEARBROWSER_HOME or place the file at {candidate}" + ) + + +def build_decision_record( + action: str, + profile: str, + decision: str, + risk: str, + requires_approval: bool, + reason: str, + context: dict[str, str], + dry_run: bool, +) -> dict[str, Any]: + return { + "decisionId": make_decision_id(), + "action": action, + "profile": profile, + "decision": decision, + "risk": risk, + "requiresUserApproval": requires_approval, + "reason": reason, + "timestamp": now(), + "engine": ENGINE, + "product": PRODUCT, + "schemaVersion": SCHEMA_VERSION, + "context": context, + **({"dryRun": True} if dry_run else {}), + } + + +def write_jsonl(path: Path, record: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n") + + +def parse_context(pairs: list[str]) -> dict[str, str]: + ctx: dict[str, str] = {} + for pair in pairs: + if "=" not in pair: + print(f"WARNING: context value {pair!r} has no '=', skipping", file=sys.stderr) + continue + k, _, v = pair.partition("=") + ctx[k.strip()] = v.strip() + return ctx + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> int: + parser = argparse.ArgumentParser( + description="BearBrowser local PolicyFabric enforcement engine" + ) + parser.add_argument("--action", required=True, help="Action type to evaluate") + parser.add_argument("--profile", default="human-secure", help="Policy profile (e.g. agent-runtime)") + parser.add_argument( + "--context", + nargs="*", + default=[], + metavar="KEY=VALUE", + help="Arbitrary context metadata (URL, tabId, sessionId, …)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the decision without writing to disk", + ) + args = parser.parse_args() + + # --- Validate action --- + if args.action not in KNOWN_ACTIONS: + known = ", ".join(sorted(KNOWN_ACTIONS)) + print( + f"ERROR: Unknown action type: {args.action!r}\n" + f"Known actions: {known}", + file=sys.stderr, + ) + return 1 + + # --- Load policy file --- + try: + policy_file = resolve_policy_file() + actions, profile_overrides = load_policy(policy_file) + except FileNotFoundError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + except Exception as exc: + print(f"ERROR: Failed to parse policy file: {exc}", file=sys.stderr) + return 1 + + # --- Resolve base action defaults --- + # If the action isn't in the parsed file, fall back to hard-coded defaults. + base = actions.get(args.action) or HUMAN_SECURE_DEFAULTS.get(args.action) + if base is None: + # Shouldn't happen since we validated action above, but be safe. + print(f"ERROR: No policy defaults for action {args.action!r}", file=sys.stderr) + return 1 + + decision = base["decision"] + risk = base["risk"] + requires_approval = base["requiresUserApproval"] + reason = base["reason"] + + # --- Apply profile overrides --- + known_profiles = set(profile_overrides.keys()) | {"human-secure", "bootstrap", "unknown"} + if args.profile not in known_profiles and args.profile != "human-secure": + print( + f"WARNING: Unknown profile {args.profile!r}. " + f"Defaulting to human-secure (most restrictive) settings.", + file=sys.stderr, + ) + else: + overrides_for_profile = profile_overrides.get(args.profile, {}) + overrides_for_action = overrides_for_profile.get(args.action, {}) + if "decision" in overrides_for_action: + decision = overrides_for_action["decision"] + if "requiresUserApproval" in overrides_for_action: + requires_approval = overrides_for_action["requiresUserApproval"] + + # --- Build record --- + context = parse_context(args.context) + record = build_decision_record( + action=args.action, + profile=args.profile, + decision=decision, + risk=risk, + requires_approval=requires_approval, + reason=reason, + context=context, + dry_run=args.dry_run, + ) + + # --- Output --- + print(json.dumps(record, indent=2)) + + # --- Persist --- + if not args.dry_run: + write_jsonl(decisions_path(), record) + if decision == "hold": + write_jsonl(hold_queue_path(), record) + + # --- Exit code --- + if decision == "deny": + return 3 + if decision == "hold": + return 2 + return 0 # allow / observe + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/bearbrowser-sidecar-server.py b/scripts/bearbrowser-sidecar-server.py index fe14e16..bf7cf70 100644 --- a/scripts/bearbrowser-sidecar-server.py +++ b/scripts/bearbrowser-sidecar-server.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import datetime as dt import html import json import secrets @@ -42,6 +43,14 @@ def default_comparisons() -> Path: return support_dir() / "comparisons" / "page-comparisons.jsonl" +def default_receipts() -> Path: + return support_dir() / "receipts" / "receipts.jsonl" + + +def default_hold_queue() -> Path: + return support_dir() / "policy" / "hold-queue.jsonl" + + def token_path() -> Path: return support_dir() / "sidecar" / "server-token" @@ -80,6 +89,16 @@ def read_jsonl(path: Path) -> list[dict[str, Any]]: return records +def latest_receipts(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return the most recent version of each receipt (by receiptId).""" + by_id: dict[str, dict[str, Any]] = {} + for record in records: + rid = record.get("receiptId", "") + if rid: + by_id[rid] = record + return list(by_id.values()) + + def unresolved_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]: resolved = set() for action in actions: @@ -219,46 +238,225 @@ def render_table_comparisons(records: list[dict[str, Any]]) -> str: return "" + "".join(rows) + "
TimeInputsClassDecisionComparison
" +STATUS_BADGE_COLORS = { + "active": "#2ecc71", + "ended": "#888888", + "revoked": "#e74c3c", + "failed": "#e67e22", + "denied": "#c0392b", + "orphaned": "#7f8c8d", +} + + +def status_badge(status: str) -> str: + color = STATUS_BADGE_COLORS.get(status, "#555") + return ( + f"" + f"{esc(status)}" + ) + + +def render_table_receipts(receipts: list[dict[str, Any]]) -> str: + if not receipts: + return '

No automation receipts yet.

' + active = [r for r in receipts if r.get("status") == "active"] + others = [r for r in receipts if r.get("status") != "active"] + ordered = list(reversed(active)) + list(reversed(others)) + rows = [] + for receipt in ordered: + rid = esc(receipt.get("receiptId", "")) + short_id = rid.split(":")[-1][:12] if ":" in rid else rid[:12] + rows.append( + "" + f"{short_id}…" + f"{esc(receipt.get('transport', ''))}" + f"{esc(receipt.get('ownerRef', '').split(':')[-1])}" + f"{esc(receipt.get('displayName', ''))}" + f"{esc(receipt.get('capturedAt', ''))}" + f"{status_badge(receipt.get('status', ''))}" + "" + ) + return ( + "" + "" + "" + "".join(rows) + "
ReceiptTransportOwnerSessionCreatedStatus
" + ) + + +def render_table_hold_queue(records: list[dict[str, Any]], token: str) -> str: + if not records: + return '

No held policy decisions.

' + rows = [] + for record in reversed(records): + decision_id = esc(record.get("decisionId", record.get("decision_id", ""))) + rows.append( + "" + f"{decision_id}" + f"{esc(record.get('actionType', record.get('action_type', '')))}" + f"{esc(record.get('reason', ''))}" + f"{esc(record.get('requestedAt', record.get('timestamp', '')))}" + "" + f"
" + f"" + f"" + f"
" + f"
" + f"" + f"" + f"
" + "" + ) + return ( + "" + "" + "" + "".join(rows) + "
Decision IDActionReasonRequestedResolve
" + ) + + def render_page(token: str, message: str = "") -> str: events = read_jsonl(default_events()) actions = unresolved_actions(read_jsonl(default_actions())) memory = pending_memory(read_jsonl(default_memory())) summaries = read_jsonl(default_summaries()) comparisons = read_jsonl(default_comparisons()) + receipts = latest_receipts(read_jsonl(default_receipts())) + hold_queue = read_jsonl(default_hold_queue()) + + active_sessions = [r for r in receipts if r.get("status") == "active"] + now_str = dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + notice = f"
{esc(message)}
" if message else "" return f""" -BearBrowser Interactive Sidecar + +BearBrowser Governance +
+
+ local sidecar +

BearBrowser Governance

+
+
{esc(now_str)}
+
-interactive local sidecar -

BearBrowser Governance Queue

-

Local-only queue for held actions, pending memory candidates, read-only page summaries, and held page comparisons.

{notice}
+
{len(active_sessions)}

active sessions

+
{len(receipts)}

total receipts

+
{len(hold_queue)}

hold queue

{len(events)}

provenance events

{len(actions)}

held actions

{len(memory)}

pending memory

-
{len(summaries)}

page summaries

-
{len(comparisons)}

comparisons

-

Held Policy Actions

{render_table_actions(actions, token)}
-

Pending Memory Candidates

{render_table_memory(memory, token)}
-

Recent Page Comparisons

{render_table_comparisons(comparisons)}
-

Recent Page Summaries

{render_table_summaries(summaries)}
+ +
+

Automation Sessions (Receipts)

+ {render_table_receipts(receipts)} +
+ +
+

Hold Queue

+ {render_table_hold_queue(hold_queue, token)} +
+ +
+

Held Policy Actions

+ {render_table_actions(actions, token)} +
+ +
+

Pending Memory Candidates

+ {render_table_memory(memory, token)} +
+ +
+

Recent Events

+

{len(events)} provenance event(s) recorded.

+
+ +
+

Recent Page Comparisons

+ {render_table_comparisons(comparisons)} +
+ +
+

Recent Page Summaries

+ {render_table_summaries(summaries)} +
""" +def resolve_hold_decision(decision_id: str, resolution: str) -> tuple[int, str]: + """Resolve a held policy decision. Calls bearbrowser-resolve-action.py if it exists, + otherwise appends a resolution record directly to hold-queue.jsonl.""" + resolve_script = SCRIPT_DIR / "bearbrowser-resolve-action.py" + if resolve_script.exists(): + return run_command([ + sys.executable, str(resolve_script), + "--action-id", decision_id, + "--decision", resolution, + "--actor-type", "human", + "--actor-id", "sidecar", + "--reason", f"{resolution.capitalize()}d from interactive sidecar.", + ]) + + # Fallback: append a resolution record directly to hold-queue.jsonl + import datetime as _dt + hold_queue_path = default_hold_queue() + hold_queue_path.parent.mkdir(parents=True, exist_ok=True) + record = { + "schemaVersion": "bearbrowser.hold_queue_resolution.v1", + "decisionId": decision_id, + "resolution": resolution, + "resolvedBy": "sidecar", + "resolvedAt": _dt.datetime.now(_dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "reason": f"{resolution.capitalize()}d from interactive sidecar.", + } + try: + with hold_queue_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n") + return 0, f"Resolution appended to {hold_queue_path}" + except OSError as exc: + return 1, f"Failed to append resolution: {exc}" + + class Handler(BaseHTTPRequestHandler): server_version = "BearBrowserSidecar/0.1" @@ -276,6 +474,15 @@ def send_html(self, html_text: str, status: int = 200) -> None: self.end_headers() self.wfile.write(data) + def send_json(self, payload: Any, status: int = 200) -> None: + data = json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(data) + def read_form(self) -> dict[str, str]: length = int(self.headers.get("Content-Length", "0") or "0") raw = self.rfile.read(length).decode("utf-8") if length else "" @@ -283,6 +490,19 @@ def read_form(self) -> dict[str, str]: return {key: values[0] for key, values in parsed.items() if values} def do_GET(self) -> None: # noqa: N802 + parsed = urllib.parse.urlparse(self.path) + + # Read-only JSON API endpoints (no token required — localhost only, read-only) + if parsed.path == "/api/receipts": + receipts = latest_receipts(read_jsonl(default_receipts())) + self.send_json({"receipts": receipts, "count": len(receipts)}) + return + + if parsed.path == "/api/hold-queue": + records = read_jsonl(default_hold_queue()) + self.send_json({"holdQueue": records, "count": len(records)}) + return + if not self.token_ok(): self.send_html("

BearBrowser sidecar denied

Invalid token.

", 403) return @@ -293,8 +513,10 @@ def do_POST(self) -> None: # noqa: N802 self.send_html("

BearBrowser sidecar denied

Invalid token.

", 403) return parsed = urllib.parse.urlparse(self.path) + q = urllib.parse.parse_qs(parsed.query) form = self.read_form() message = "No action taken." + if parsed.path == "/action/allow": action_id = form.get("action_id", "") code, out = run_command(tool_script("bearbrowser-resolve-action") + ["--action-id", action_id, "--decision", "allow", "--actor-type", "human", "--actor-id", "sidecar", "--reason", "Allowed from interactive sidecar."]) @@ -311,6 +533,24 @@ def do_POST(self) -> None: # noqa: N802 memory_id = form.get("memory_id", "") code, out = run_command(tool_script("bearbrowser-memory-candidate") + ["resolve", "--memory-id", memory_id, "--decision", "reject", "--actor-type", "human", "--actor-id", "sidecar", "--reason", "Rejected from interactive sidecar."]) message = "Rejected memory candidate." if code == 0 else f"Reject failed: {out}" + elif parsed.path == "/resolve": + # Support both query-string and form-body parameters for decision_id and resolution + decision_id = ( + q.get("decision_id", [""])[0] + or form.get("decision_id", "") + ) + resolution = ( + q.get("resolution", [""])[0] + or form.get("resolution", "") + ) + if not decision_id: + message = "ERROR: decision_id is required." + elif resolution not in {"allow", "deny"}: + message = f"ERROR: resolution must be allow or deny, got {resolution!r}." + else: + code, out = resolve_hold_decision(decision_id, resolution) + message = f"{resolution.capitalize()}d decision {decision_id}." if code == 0 else f"Resolve failed: {out}" + self.send_html(render_page(self.server.token, message)) # type: ignore[attr-defined] def log_message(self, fmt: str, *args: Any) -> None: diff --git a/scripts/bearbrowser-update-receipt.py b/scripts/bearbrowser-update-receipt.py new file mode 100755 index 0000000..5096fb2 --- /dev/null +++ b/scripts/bearbrowser-update-receipt.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Update the status of a BearBrowser BrowserAutomationReceipt in place.""" +from __future__ import annotations + +import argparse +import datetime as dt +import json +import sys +from pathlib import Path + +VALID_STATUSES = {"ended", "failed", "revoked", "orphaned", "denied"} + + +def now() -> str: + return dt.datetime.now(dt.UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def receipts_dir() -> Path: + return Path.home() / "Library" / "Application Support" / "BearBrowser" / "receipts" + + +def resolve_receipt_path(receipt_id: str) -> Path: + """Return the individual receipt JSON file path, accepting either the full URN or a bare UUID.""" + rdir = receipts_dir() + # Accept full URN like urn:srcos:receipt:browser-automation: + if receipt_id.startswith("urn:"): + bare = receipt_id.split(":")[-1] + else: + bare = receipt_id + return rdir / f"{bare}.json" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Update the status of a BearBrowser BrowserAutomationReceipt" + ) + parser.add_argument("--receipt-id", required=True, help="Receipt URN or bare UUID") + parser.add_argument( + "--status", + required=True, + choices=sorted(VALID_STATUSES), + help="New status to set on the receipt", + ) + args = parser.parse_args() + + path = resolve_receipt_path(args.receipt_id) + if not path.exists(): + print(f"ERROR: receipt file not found: {path}", file=sys.stderr) + return 1 + + try: + receipt = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + print(f"ERROR: invalid JSON in receipt file {path}: {exc}", file=sys.stderr) + return 1 + + if not isinstance(receipt, dict): + print(f"ERROR: receipt file must contain a JSON object: {path}", file=sys.stderr) + return 1 + + old_status = receipt.get("status", "") + receipt["status"] = args.status + + timestamp = now() + if args.status == "revoked": + receipt["revokedAt"] = timestamp + elif args.status in {"ended", "failed"}: + receipt["terminatedAt"] = timestamp + + try: + path.write_text(json.dumps(receipt, indent=2, sort_keys=True), encoding="utf-8") + except OSError as exc: + print(f"ERROR: could not write updated receipt to {path}: {exc}", file=sys.stderr) + return 1 + + jsonl_path = receipts_dir() / "receipts.jsonl" + try: + with jsonl_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n") + except OSError as exc: + print(f"ERROR: could not append updated receipt to receipts.jsonl: {exc}", file=sys.stderr) + return 1 + + print(f"receipt_id={receipt.get('receiptId', args.receipt_id)}") + print(f"status={old_status} -> {args.status}") + print(f"updated_at={timestamp}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/bearbrowser-verify-policy-engine.py b/scripts/bearbrowser-verify-policy-engine.py new file mode 100755 index 0000000..c7fe99a --- /dev/null +++ b/scripts/bearbrowser-verify-policy-engine.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +BearBrowser policy engine verifier. + +Runs the policy engine for every known action type in dry-run mode and verifies: + - Required fields are present in the JSON output + - Exit code 0 for allow/observe decisions + - Exit code 2 for hold decisions + - Exit code 3 for deny decisions + +Run standalone: + python3 bearbrowser-verify-policy-engine.py +""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +# --------------------------------------------------------------------------- +# Test cases: (action, profile, expected_decision, expected_exit_code) +# --------------------------------------------------------------------------- + +REQUIRED_FIELDS = { + "decisionId", + "action", + "profile", + "decision", + "risk", + "requiresUserApproval", + "reason", + "timestamp", + "engine", + "product", + "schemaVersion", + "context", +} + +# Map of decision -> expected exit code +DECISION_EXIT_CODES = { + "allow": 0, + "observe": 0, + "hold": 2, + "deny": 3, +} + +# All known actions with their expected decisions per profile +# (action, profile, expected_decision) +TEST_CASES: list[tuple[str, str, str]] = [ + # human-secure profile (default) + ("navigate", "human-secure", "allow"), + ("summarize_page", "human-secure", "observe"), + ("compare_tabs", "human-secure", "hold"), + ("share_page_with_agent", "human-secure", "hold"), + ("request_credential", "human-secure", "hold"), + ("request_autofill", "human-secure", "hold"), + ("download_file", "human-secure", "hold"), + ("upload_file", "human-secure", "hold"), + ("read_clipboard", "human-secure", "hold"), + ("write_clipboard", "human-secure", "hold"), + ("run_automation", "human-secure", "hold"), + ("write_memory_candidate","human-secure", "hold"), + ("commit_memory", "human-secure", "hold"), + # agent-runtime profile overrides + ("navigate", "agent-runtime", "hold"), + ("request_credential", "agent-runtime", "deny"), + ("request_autofill", "agent-runtime", "deny"), + ("upload_file", "agent-runtime", "hold"), + ("run_automation", "agent-runtime", "hold"), + # non-overridden actions fall through to base defaults in agent-runtime + ("summarize_page", "agent-runtime", "observe"), + ("download_file", "agent-runtime", "hold"), +] + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +SCRIPT = Path(__file__).resolve().parent / "bearbrowser-policy-engine.py" + + +def run_engine(action: str, profile: str) -> tuple[int, dict[str, Any] | None, str]: + """ + Run the policy engine in dry-run mode. + Returns (exit_code, parsed_json_or_None, raw_stdout). + """ + result = subprocess.run( + [sys.executable, str(SCRIPT), + "--action", action, + "--profile", profile, + "--context", "sessionId=verify-test", "tabId=0", + "--dry-run"], + capture_output=True, + text=True, + ) + raw = result.stdout.strip() + parsed = None + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, ValueError): + pass + return result.returncode, parsed, raw + + +def check_required_fields(record: dict[str, Any]) -> list[str]: + missing = [] + for field in sorted(REQUIRED_FIELDS): + if field not in record: + missing.append(field) + return missing + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> int: + if not SCRIPT.exists(): + print(f"ERROR: Policy engine not found at {SCRIPT}", file=sys.stderr) + return 1 + + pass_count = 0 + fail_count = 0 + results: list[tuple[str, bool, str]] = [] + + print(f"BearBrowser policy engine verifier") + print(f"Engine: {SCRIPT}") + print(f"Tests: {len(TEST_CASES)}") + print() + + for action, profile, expected_decision in TEST_CASES: + label = f"{action} [{profile}]" + exit_code, record, raw = run_engine(action, profile) + expected_code = DECISION_EXIT_CODES.get(expected_decision, 0) + + errors: list[str] = [] + + # 1. Parse check + if record is None: + errors.append(f"JSON parse failed. Raw output: {raw[:200]!r}") + else: + # 2. Required fields + missing = check_required_fields(record) + if missing: + errors.append(f"Missing fields: {', '.join(missing)}") + + # 3. Decision value + got_decision = record.get("decision") + if got_decision != expected_decision: + errors.append(f"decision={got_decision!r}, expected={expected_decision!r}") + + # 4. Dry-run flag present + if not record.get("dryRun"): + errors.append("dryRun field missing or false in dry-run output") + + # 5. Schema version + if record.get("schemaVersion") != "bearbrowser.policy_decision.v1": + errors.append(f"schemaVersion={record.get('schemaVersion')!r}") + + # 6. Action echo + if record.get("action") != action: + errors.append(f"action field={record.get('action')!r}, expected={action!r}") + + # 7. Profile echo + if record.get("profile") != profile: + errors.append(f"profile field={record.get('profile')!r}, expected={profile!r}") + + # 8. Exit code + if exit_code != expected_code: + errors.append(f"exit code={exit_code}, expected={expected_code}") + + passed = len(errors) == 0 + if passed: + pass_count += 1 + results.append((label, True, "")) + else: + fail_count += 1 + results.append((label, False, "; ".join(errors))) + + # --- Report --- + width = max(len(r[0]) for r in results) + 2 + for label, passed, detail in results: + status = "PASS" if passed else "FAIL" + line = f" [{status}] {label:<{width}}" + if not passed: + line += f" {detail}" + print(line) + + print() + + # --- Additional: invalid action test --- + print(" Checking unknown action → exit code 1 ...") + res = subprocess.run( + [sys.executable, str(SCRIPT), "--action", "totally_bogus_action", + "--profile", "human-secure", "--dry-run"], + capture_output=True, text=True, + ) + if res.returncode == 1: + print(" [PASS] Unknown action exits with code 1") + pass_count += 1 + else: + print(f" [FAIL] Unknown action returned exit code {res.returncode}, expected 1") + fail_count += 1 + + # --- Summary --- + total = pass_count + fail_count + print() + print(f"Results: {pass_count}/{total} passed, {fail_count} failed") + + return 0 if fail_count == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/verify-macos-app.sh b/scripts/verify-macos-app.sh index 973548c..711d759 100644 --- a/scripts/verify-macos-app.sh +++ b/scripts/verify-macos-app.sh @@ -2,12 +2,19 @@ set -euo pipefail app_path="${BEARBROWSER_APP_PATH:-build/macos/BearBrowser.app}" +skip_signing="false" usage() { cat <<'USAGE' -Usage: verify-macos-app [--app PATH] +Usage: verify-macos-app [--app PATH] [--skip-signing] Verifies BearBrowser.app product identity, signing, and Gatekeeper status. + +Options: + --app Path to the app bundle. Default: build/macos/BearBrowser.app. + --skip-signing Skip codesign verification and spctl Gatekeeper assessment. + Use for binary overlay builds (ad-hoc signatures only) and CI + environments without a valid Developer ID certificate. USAGE } @@ -17,6 +24,10 @@ while [ "$#" -gt 0 ]; do app_path="${2:?missing app path}" shift 2 ;; + --skip-signing) + skip_signing="true" + shift + ;; -h|--help) usage exit 0 @@ -45,7 +56,7 @@ display_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleDisplayName' "$plist" bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$plist" 2>/dev/null || true)" if [ "$name" != "BearBrowser" ] && [ "$display_name" != "BearBrowser" ]; then - echo "ERROR: bundle name/display name must be BearBrowser" >&2 + echo "ERROR: bundle name/display name must be BearBrowser; found name='$name' display_name='$display_name'" >&2 exit 1 fi @@ -54,13 +65,44 @@ if [ "$bundle_id" != "dev.sourceos.BearBrowser" ]; then exit 1 fi -if grep -RIlE 'LibreWolf|librewolf|Libre Wolf' "$app_path/Contents" 2>/dev/null | grep -vE '/(_CodeSignature|LICENSE|COPYING|README|legal|licenses)/' | head -1 | grep -q .; then - echo "ERROR: product-surface upstream branding found inside app bundle" >&2 - grep -RIlE 'LibreWolf|librewolf|Libre Wolf' "$app_path/Contents" 2>/dev/null | grep -vE '/(_CodeSignature|LICENSE|COPYING|README|legal|licenses)/' | head -20 >&2 +# Verify the BearBrowser launcher wrapper exists as the declared CFBundleExecutable. +bundle_executable="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$plist" 2>/dev/null || true)" +if [ "$bundle_executable" != "BearBrowser" ]; then + echo "ERROR: CFBundleExecutable must be 'BearBrowser'; found '$bundle_executable'" >&2 exit 1 fi +launcher="$app_path/Contents/MacOS/BearBrowser" +if [ ! -f "$launcher" ] || [ ! -x "$launcher" ]; then + echo "ERROR: BearBrowser launcher not found or not executable: $launcher" >&2 + exit 1 +fi + +# Check for upstream branding in text-format product-surface files. +# grep -I skips binary files, so compiled engine code is intentionally excluded — +# only metadata, desktop files, plist files, and scripts are checked. +# Exclusions: +# - Code signature directories (_CodeSignature) +# - License/attribution files (required to preserve upstream provenance) +# - BearBrowser-injected profile files (user.js, bearbrowser-user.js) — these +# may reference "LibreWolf" in comments describing what the upstream does +# - The BearBrowser launcher wrapper itself +branding_hits="$(grep -RIlE 'LibreWolf|librewolf|Libre Wolf' "$app_path/Contents" 2>/dev/null \ + | grep -vE '/(_CodeSignature|LICENSE|COPYING|README|legal|licenses|legal-notices|license-notices|attribution)/' \ + | grep -vE '/(bearbrowser-user\.js|user\.js|bearbrowser-metadata/)' \ + | grep -v '/Contents/MacOS/BearBrowser$' \ + || true)" -codesign --verify --deep --strict --verbose=2 "$app_path" -spctl --assess --type execute --verbose=4 "$app_path" +if [ -n "$branding_hits" ]; then + echo "ERROR: upstream branding found in text-format product-surface files:" >&2 + echo "$branding_hits" | head -20 >&2 + exit 1 +fi + +if [ "$skip_signing" = "true" ]; then + echo "note: skipping code signature and Gatekeeper verification (--skip-signing)" +else + codesign --verify --deep --strict --verbose=2 "$app_path" + spctl --assess --type execute --verbose=4 "$app_path" +fi echo "BearBrowser macOS app verified: $app_path" diff --git a/settings/profiles/agent-runtime/policies.json b/settings/profiles/agent-runtime/policies.json index 12f495b..8dced64 100644 --- a/settings/profiles/agent-runtime/policies.json +++ b/settings/profiles/agent-runtime/policies.json @@ -1,13 +1,246 @@ { + // =========================================================================== + // BearBrowser — Agent Browser Runtime Profile: Enterprise Policies + // =========================================================================== + // This file is loaded by the Firefox/LibreWolf Enterprise Policy engine and + // applies to the agent-runtime profile directory only. + // + // Authority model: + // PolicyFabric (this file) > user.js prefs > LibreWolf defaults. + // + // All settings that control security-relevant surfaces carry "Locked": true + // so that neither user interaction nor agent-injected script can override + // them at runtime. The orchestration layer is the only entity permitted to + // modify these policies — by replacing this file between tasks. + // + // Relationship to user.js: + // user.js sets the same values as belt-and-suspenders so they apply even + // when the Policy engine is bypassed (e.g. dev/test mode without policies). + // Where a value is set in both files, this file takes precedence. + // =========================================================================== "policies": { + + // ------------------------------------------------------------------------- + // Telemetry, studies, and data collection + // ------------------------------------------------------------------------- "DisableTelemetry": true, "DisableFirefoxStudies": true, + + // ------------------------------------------------------------------------- + // Firefox services the agent profile does not use + // ------------------------------------------------------------------------- "DisablePocket": true, "DisableFirefoxAccounts": true, + "DisableSyncedTabsButton": true, + "DisableAccounts": true, + + // ------------------------------------------------------------------------- + // Default browser / onboarding noise + // ------------------------------------------------------------------------- "DontCheckDefaultBrowser": true, + "OverrideFirstRunPage": "", + "OverridePostUpdatePage": "", + "NoDefaultBookmarks": true, + + // ------------------------------------------------------------------------- + // Credentials and autofill — none of these should operate in agent sessions + // ------------------------------------------------------------------------- "OfferToSaveLogins": false, + "PasswordManagerEnabled": false, + "AutofillCreditCardEnabled": false, + "AutofillAddressEnabled": false, + // Prevent the password manager UI from appearing at all. "DisableMasterPasswordCreation": true, + + // ------------------------------------------------------------------------- + // Update policy — agent runtime must be versioned and stable. + // Updates are applied by the platform build pipeline, not at browser launch. + // Auto-updating an agent browser mid-session could change behaviour or + // restart the process, aborting an in-flight task. + // ------------------------------------------------------------------------- + "DisableAppUpdate": true, + "ExtensionUpdate": false, + "DisableSystemAddonUpdate": true, + + // ------------------------------------------------------------------------- + // Developer tools — agents use remote debugging via CDP/WebDriver, + // not the interactive DevTools UI. Keep false so automation still works. + // ------------------------------------------------------------------------- + "DisableDeveloperTools": false, + + // ------------------------------------------------------------------------- + // about: page lockdown + // Blocking these pages prevents an agent from being redirected to + // privileged UI surfaces via malicious page content. + // ------------------------------------------------------------------------- + "BlockAboutAddons": true, + "BlockAboutConfig": true, + "BlockAboutProfiles": true, + "BlockAboutSupport": true, + + // ------------------------------------------------------------------------- + // Extension installation lockdown + // Agents must not install extensions interactively. Approved extensions are + // pre-installed by the platform build and listed in the allowlist below. + // ------------------------------------------------------------------------- + "InstallAddonsPermission": { + "Default": false + }, + "ExtensionSettings": { + // Block all extensions by default; platform build installs approved ones. + "*": { + "installation_mode": "blocked" + } + }, + + // ------------------------------------------------------------------------- + // Hardware / sensor permissions + // Locked: true prevents sites from prompting; the agent cannot grant these. + // ------------------------------------------------------------------------- + "Permissions": { + "Camera": { + "BlockNewRequests": true, + "Locked": true + }, + "Microphone": { + "BlockNewRequests": true, + "Locked": true + }, + "Location": { + "BlockNewRequests": true, + "Locked": true + }, + "Notifications": { + "BlockNewRequests": true, + "Locked": true + }, + "Autoplay": { + "Default": "block-audio-video", + "Locked": true + } + }, + + // ------------------------------------------------------------------------- + // Sanitize on shutdown (policy layer) + // belt-and-suspenders with user.js privacy.clearOnShutdown.* prefs. + // Locked prevents the agent or any site from turning this off. + // ------------------------------------------------------------------------- + "SanitizeOnShutdown": { + "Cache": true, + "Cookies": true, + "Downloads": true, + "FormData": true, + "History": true, + "Sessions": true, + "SiteSettings": false, + "OfflineApps": true, + "Locked": true + }, + + // ------------------------------------------------------------------------- + // DNS over HTTPS — strict mode, no system-resolver fallback. + // Fallback: false means if DoH fails, the request fails (not silently + // retried over plain DNS). This prevents DNS exfiltration via fallback. + // ------------------------------------------------------------------------- + "DNSOverHTTPS": { + "Enabled": true, + "ProviderURL": "https://1.1.1.1/dns-query", + "Fallback": false, + "Locked": true + }, + + // ------------------------------------------------------------------------- + // HTTPS-only mode — locked so agent cannot navigate to plain HTTP. + // "force_enabled" applies in both normal and private browsing windows. + // ------------------------------------------------------------------------- + "HttpsOnlyMode": "force_enabled", + + // ------------------------------------------------------------------------- + // Enhanced Tracking Protection — strict tier, all categories enabled. + // ------------------------------------------------------------------------- + "EnableTrackingProtection": { + "Value": true, + "Cryptomining": true, + "Fingerprinting": true, + "EmailTracking": true, + "Locked": true + }, + + // ------------------------------------------------------------------------- + // Cookies — reject all third-party; first-party partitioned via dFPI prefs. + // "reject-foreign" maps to cookieBehavior=1; combined with dFPI user.js + // prefs this provides Total Cookie Protection semantics via policy. + // ------------------------------------------------------------------------- + "Cookies": { + "Behavior": "reject-foreign", + "BehaviorPrivateBrowsing": "reject-foreign", + "Locked": true + }, + + // ------------------------------------------------------------------------- + // Search engines — remove data-broker engines that are pre-installed. + // Agents should use the orchestration layer's search abstraction, not the + // browser's built-in search bar sending queries to commercial providers. + // ------------------------------------------------------------------------- + "SearchEngines": { + "Remove": ["Google", "Bing", "Amazon.com", "eBay", "Twitter", "Wikipedia"] + }, + "SearchSuggestEnabled": false, + + // ------------------------------------------------------------------------- + // Firefox Home (new tab page) — all content tiles off. + // New tab should be a blank page; any live content could trigger network + // requests outside the agent's explicit navigation intent. + // ------------------------------------------------------------------------- + "FirefoxHome": { + "Search": false, + "TopSites": false, + "SponsoredTopSites": false, + "Highlights": false, + "Pocket": false, + "SponsoredPocket": false, + "Snippets": false, + "Locked": true + }, + + // ------------------------------------------------------------------------- + // User messaging / onboarding suppression + // ------------------------------------------------------------------------- + "UserMessaging": { + "WhatsNew": false, + "ExtensionRecommendations": false, + "FeatureRecommendations": false, + "UrlbarInterventions": false, + "SkipOnboarding": true, + "MoreFromMozilla": false, + "FirefoxLabs": false, + "Locked": true + }, + + // ------------------------------------------------------------------------- + // Miscellaneous UI lockdown + // ------------------------------------------------------------------------- "DisableSetDesktopBackground": true, - "DisableDeveloperTools": false + "DisablePrivateBrowsing": false, // private browsing must remain available + // (autostart is set in user.js) + "DisableProfileImport": true, + "DisableProfileRefresh": true, + "DisableThirdPartyModuleBlocking": false, + + // ------------------------------------------------------------------------- + // Pop-up windows — agents should not have UI pop-ups steal focus. + // ------------------------------------------------------------------------- + "PopupBlocking": { + "Default": true, + "Locked": true + }, + + // ------------------------------------------------------------------------- + // Flash / NPAPI plugins — should never be present but ensure off. + // ------------------------------------------------------------------------- + "FlashPlugin": { + "Default": "block", + "Locked": true + } } } diff --git a/settings/profiles/agent-runtime/user.js b/settings/profiles/agent-runtime/user.js index 3775059..d584774 100644 --- a/settings/profiles/agent-runtime/user.js +++ b/settings/profiles/agent-runtime/user.js @@ -1,9 +1,285 @@ -// SourceOS Agent Browser Runtime baseline preferences. -// Final values must be reconciled against LibreWolf defaults and PolicyFabric. +// ============================================================================= +// BearBrowser — Agent Browser Runtime Profile +// ============================================================================= +// This file configures Firefox/LibreWolf prefs for the Agent Browser Runtime +// mode of BearBrowser. It is loaded from the agent-runtime profile directory +// and is intended to be used exclusively by automated AI agents — never by +// human users browsing interactively. +// +// Design goals: +// 1. Zero state accumulation: nothing written to disk persists across sessions. +// 2. No credential inheritance: agents must not pick up cookies, saved logins, +// or session tokens left by any previous session (human or agent). +// 3. No data exfiltration surfaces: WebRTC, push, service workers, and other +// side-channels that can leak IP/identity are disabled entirely. +// 4. Strong fingerprint resistance: agents should blend into the same +// privacy-hardened baseline as human-secure profiles. +// 5. Minimal attack surface: features that agents have no operational need +// for (camera, microphone, VR, battery API, geolocation) are fully off. +// +// PolicyFabric is the authority for all per-site overrides. Prefs here are the +// floor; policies.json enforces the ceiling via the Enterprise Policy engine. +// ============================================================================= -user_pref("browser.download.useDownloadDir", true); -user_pref("browser.download.always_ask_before_handling_new_types", false); +// ----------------------------------------------------------------------------- +// 1. SESSION / STORAGE ISOLATION +// No state should survive a session boundary. Every agent task starts clean. +// ----------------------------------------------------------------------------- + +// Do not write session data to disk at all. +user_pref("browser.sessionstore.enabled", false); user_pref("browser.sessionstore.resume_from_crash", false); +user_pref("browser.sessionstore.resume_session_once", false); +// Flush session store immediately on close so nothing lingers. +user_pref("browser.sessionstore.interval", 2147483647); // max int — effectively never checkpoint + +// No browsing history (Places database stays empty). +user_pref("places.history.enabled", false); + +// No form autofill or saved logins. user_pref("signon.rememberSignons", false); +user_pref("signon.autofillForms", false); +user_pref("signon.generation.enabled", false); +user_pref("signon.management.page.breach-alerts.enabled", false); +user_pref("browser.formfill.enable", false); + +// No offline storage / IndexedDB persistence across profiles. +// (Per-session IndexedDB is still allowed for site functionality; +// private browsing mode handles isolation via autostart below.) +user_pref("browser.cache.offline.enable", false); +user_pref("browser.cache.offline.capacity", 0); + +// Sanitize everything on shutdown — belt-and-suspenders on top of private mode. +user_pref("privacy.sanitize.sanitizeOnShutdown", true); +user_pref("privacy.clearOnShutdown.cache", true); +user_pref("privacy.clearOnShutdown.cookies", true); +user_pref("privacy.clearOnShutdown.downloads", true); +user_pref("privacy.clearOnShutdown.formdata", true); +user_pref("privacy.clearOnShutdown.history", true); +user_pref("privacy.clearOnShutdown.offlineApps", true); +user_pref("privacy.clearOnShutdown.sessions", true); +user_pref("privacy.clearOnShutdown.siteSettings", false); // keep pref overrides +user_pref("privacy.clearOnShutdown.openWindows", true); +// v2 API (FF 128+) +user_pref("privacy.clearOnShutdown_v2.cache", true); +user_pref("privacy.clearOnShutdown_v2.cookiesAndStorage", true); +user_pref("privacy.clearOnShutdown_v2.downloads", true); +user_pref("privacy.clearOnShutdown_v2.formdata", true); +user_pref("privacy.clearOnShutdown_v2.historyFormDataAndDownloads", true); + +// No download history written to disk. +user_pref("browser.download.useDownloadDir", false); +user_pref("browser.download.always_ask_before_handling_new_types", true); +user_pref("browser.download.manager.addToRecentDocs", false); +// Agent downloads should be explicitly handled by the orchestration layer, not +// auto-opened or stored in a fixed dir where other processes could read them. + +// ----------------------------------------------------------------------------- +// 2. PRIVACY / FINGERPRINTING — human-secure baseline +// Agents must be indistinguishable from privacy-hardened human browsers. +// Unique fingerprints would allow cross-session tracking of agent tasks. +// ----------------------------------------------------------------------------- + +// Master fingerprint resistance switch (RFP). +user_pref("privacy.resistFingerprinting", true); +user_pref("privacy.resistFingerprinting.block_mozAddonManager", true); + +// Letterboxing: round window dimensions to standard buckets so viewport size +// cannot be used as a fingerprint component. +user_pref("privacy.resistFingerprinting.letterboxing", true); + +// Randomise canvas output (RFP covers this, but be explicit). +user_pref("privacy.resistFingerprinting.randomDataOnCanvasExtract", true); + +// Strict Enhanced Tracking Protection. +user_pref("browser.contentblocking.category", "strict"); +user_pref("privacy.trackingprotection.enabled", true); +user_pref("privacy.trackingprotection.socialtracking.enabled", true); +user_pref("privacy.trackingprotection.cryptomining.enabled", true); +user_pref("privacy.trackingprotection.fingerprinting.enabled", true); +user_pref("privacy.trackingprotection.emailtracking.enabled", true); +user_pref("privacy.trackingprotection.pbmode.enabled", true); + +// Dynamic First-Party Isolation (dFPI / Total Cookie Protection). +// Partitions all storage by top-level eTLD+1, preventing cross-site tracking +// even within a single agent session. +user_pref("network.cookie.cookieBehavior", 5); // reject third-party, partition first-party +user_pref("network.cookie.cookieBehavior.pbmode", 5); +user_pref("privacy.partition.network_state", true); +user_pref("privacy.partition.network_state.ocsp_cache", true); +user_pref("privacy.partition.serviceWorkers", true); +user_pref("privacy.partition.bloburl_per_agent_cluster", true); +user_pref("privacy.firstparty.isolate", false); // dFPI supersedes legacy FPI; avoid conflict + +// DNS over HTTPS — strict mode, no fallback to OS resolver. +// Prevents DNS-level eavesdropping and poisoning. +user_pref("network.trr.mode", 3); // 3 = TRR only, hard-fail +user_pref("network.trr.uri", "https://1.1.1.1/dns-query"); +user_pref("network.trr.custom_uri", "https://1.1.1.1/dns-query"); + +// No link prefetch, no speculative pre-connections. +// These make network requests that agents have not explicitly authorised. +user_pref("network.prefetch-next", false); +user_pref("network.dns.disablePrefetch", true); +user_pref("network.dns.disablePrefetchFromHTTPS", true); +user_pref("network.predictor.enabled", false); +user_pref("network.predictor.enable-prefetch", false); +user_pref("network.http.speculative-parallel-limit", 0); + +// HTTPS-only mode: refuse to load any page over plain HTTP. +user_pref("dom.security.https_only_mode", true); +user_pref("dom.security.https_only_mode_pbm", true); +user_pref("dom.security.https_only_mode_ever_enabled", true); + +// Referrer policy: send only origin on cross-origin requests. +// Prevents leaking full URL path to third parties. +user_pref("network.http.referer.defaultPolicy", 2); // strict-origin-when-cross-origin +user_pref("network.http.referer.defaultPolicy.pbmode", 2); +user_pref("network.http.sendSecureXSiteReferrer", false); + +// Disable Safe Browsing — it phones home to Google with URL hashes. +// Agent traffic should not be profiled by external services. +user_pref("browser.safebrowsing.malware.enabled", false); +user_pref("browser.safebrowsing.phishing.enabled", false); +user_pref("browser.safebrowsing.blockedURIs.enabled", false); +user_pref("browser.safebrowsing.provider.google4.gethashURL", ""); +user_pref("browser.safebrowsing.provider.google4.updateURL", ""); +user_pref("browser.safebrowsing.provider.google.gethashURL", ""); +user_pref("browser.safebrowsing.provider.google.updateURL", ""); +user_pref("browser.safebrowsing.downloads.enabled", false); +user_pref("browser.safebrowsing.downloads.remote.enabled", false); +user_pref("browser.safebrowsing.passwords.enabled", false); + +// No disk cache — all agent fetches go through the memory cache only. +// This prevents cached responses from leaking between tasks or being read +// by other processes on the host. +user_pref("browser.cache.disk.enable", false); +user_pref("browser.cache.disk.capacity", 0); +user_pref("browser.cache.disk.smart_size.enabled", false); +user_pref("browser.cache.memory.enable", true); // in-memory cache is fine +user_pref("browser.cache.memory.capacity", 65536); // 64 MB ceiling + +// ----------------------------------------------------------------------------- +// 3. AGENT-SPECIFIC LOCKDOWN +// Features that agents have no operational need for are disabled here. +// The principle is: if an agent task never legitimately requires a capability, +// that capability is attack surface and must be off. +// ----------------------------------------------------------------------------- + +// --- Private browsing: force all agent sessions into private mode --- +// Private mode ensures IndexedDB, localStorage, and service worker caches are +// never written to disk, and are wiped when the window closes. This is the +// single most important isolation primitive for agent sessions. +user_pref("browser.privatebrowsing.autostart", true); + +// --- WebRTC: completely disabled --- +// WebRTC can expose the real host IP even behind a VPN/proxy via STUN probes. +// Agents must route all traffic through the designated proxy; any WebRTC leak +// would bypass that channel and expose infrastructure topology. +user_pref("media.peerconnection.enabled", false); +user_pref("media.peerconnection.ice.default_address_only", true); +user_pref("media.peerconnection.ice.no_host", true); + +// --- Camera / microphone enumeration --- +// Agents don't use A/V input. Disabling navigator.mediaDevices prevents sites +// from fingerprinting hardware configuration via device enumeration. user_pref("media.navigator.enabled", false); +user_pref("media.navigator.video.enabled", false); +user_pref("media.getusermedia.video.enabled", false); +user_pref("media.getusermedia.audio.enabled", false); + +// --- Push notifications and service workers --- +// Service workers run persistent background scripts that survive page unload. +// In an agent profile they could accumulate state, cache data, or be exploited +// to exfiltrate information out-of-band between tasks. +user_pref("dom.serviceWorkers.enabled", false); +user_pref("dom.push.enabled", false); +user_pref("dom.push.connection.enabled", false); +user_pref("dom.webnotifications.enabled", false); +user_pref("dom.webnotifications.serviceworker.enabled", false); +user_pref("dom.webnotifications.requireinteraction.enabled", false); + +// --- Hardware / sensor APIs --- +// These APIs can be used for fingerprinting and serve no agent purpose. +user_pref("device.sensors.enabled", false); +user_pref("device.sensors.ambientLight.enabled", false); +user_pref("device.sensors.motion.enabled", false); +user_pref("device.sensors.orientation.enabled", false); +user_pref("device.sensors.proximity.enabled", false); +user_pref("dom.battery.enabled", false); +user_pref("dom.vr.enabled", false); +user_pref("dom.gamepad.enabled", false); + +// --- Geolocation --- user_pref("geo.enabled", false); +user_pref("geo.provider.use_gpsd", false); +user_pref("geo.provider.use_corelocation", false); + +// --- Autocomplete / search suggestions --- +// These send partial keystrokes to external servers. Not appropriate for +// agent sessions that may type sensitive query strings. +user_pref("browser.urlbar.suggest.searches", false); +user_pref("browser.urlbar.suggest.quicksuggest.nonsponsored", false); +user_pref("browser.urlbar.suggest.quicksuggest.sponsored", false); +user_pref("browser.urlbar.quicksuggest.enabled", false); +user_pref("browser.urlbar.speculativeConnect.enabled", false); +user_pref("browser.search.suggest.enabled", false); +user_pref("browser.search.suggest.enabled.private", false); + +// --- Translation --- +// Firefox Translations runs locally, but the opt-in check can phone home. +// Disable entirely; agents can use their own translation pipeline. +user_pref("browser.translations.enable", false); + +// --- Extensions in private browsing --- +// Extensions must be explicitly allowlisted; none should auto-enable in the +// agent profile's private browsing sessions. +user_pref("extensions.allowPrivateBrowsingByDefault", false); + +// --- Miscellaneous lockdown --- +user_pref("dom.disable_window_open_feature.status", true); +user_pref("dom.disable_open_during_load", true); +user_pref("dom.popup_allowed_events", ""); +user_pref("dom.disable_window_flip", true); +user_pref("dom.disable_window_move_resize", true); +user_pref("network.http.windows-sso.enabled", false); // no Windows SSO credential injection +user_pref("network.auth.subresource-http-auth-allow", 1); // restrict HTTP auth dialogs + +// ----------------------------------------------------------------------------- +// 4. TELEMETRY — belt-and-suspenders +// These are redundant with policies.json but set here so they apply even if +// the Enterprise Policy layer is bypassed (e.g. during development). +// ----------------------------------------------------------------------------- + +user_pref("datareporting.healthreport.uploadEnabled", false); +user_pref("datareporting.policy.dataSubmissionEnabled", false); +user_pref("toolkit.telemetry.enabled", false); +user_pref("toolkit.telemetry.unified", false); +user_pref("toolkit.telemetry.archive.enabled", false); +user_pref("toolkit.telemetry.newProfilePing.enabled", false); +user_pref("toolkit.telemetry.shutdownPingSender.enabled", false); +user_pref("toolkit.telemetry.updatePing.enabled", false); +user_pref("toolkit.telemetry.bhrPing.enabled", false); +user_pref("toolkit.telemetry.firstShutdownPing.enabled", false); +user_pref("toolkit.telemetry.coverage.opt-out", true); +user_pref("toolkit.coverage.opt-out", true); +user_pref("browser.ping-centre.telemetry", false); +user_pref("browser.newtabpage.activity-stream.feeds.telemetry", false); +user_pref("browser.newtabpage.activity-stream.telemetry", false); + +// No crash reports. +user_pref("breakpad.reportURL", ""); +user_pref("browser.tabs.crashReporting.sendReport", false); +user_pref("browser.crashReports.unsubmittedCheck.autoSubmit2", false); + +// No experiments / Shield studies. +user_pref("app.shield.optoutstudies.enabled", false); +user_pref("app.normandy.enabled", false); +user_pref("app.normandy.api_url", ""); +user_pref("messaging-system.rsexperimentloader.enabled", false); + +// No Pocket. +user_pref("extensions.pocket.enabled", false); + +// No Firefox Accounts / Sync. +user_pref("identity.fxaccounts.enabled", false); diff --git a/settings/profiles/human-secure/policies.json b/settings/profiles/human-secure/policies.json index b1e5eb1..64b17ca 100644 --- a/settings/profiles/human-secure/policies.json +++ b/settings/profiles/human-secure/policies.json @@ -5,6 +5,139 @@ "DisablePocket": true, "DisableFirefoxAccounts": false, "DontCheckDefaultBrowser": true, - "OfferToSaveLogins": false + "OfferToSaveLogins": false, + "PasswordManagerEnabled": false, + "DisableFormHistory": true, + "DisableMasterPasswordCreation": false, + "DisableSystemAddonUpdate": true, + + "DNSOverHTTPS": { + "Enabled": true, + "ProviderURL": "https://1.1.1.1/dns-query", + "Fallback": false, + "Locked": false + }, + + "Cookies": { + "Behavior": "reject-tracker-and-partition-foreign", + "BehaviorPrivateBrowsing": "reject", + "ExpireAtSessionEnd": false, + "RejectTracker": true, + "Locked": false + }, + + "EnableTrackingProtection": { + "Value": true, + "Cryptomining": true, + "Fingerprinting": true, + "EmailTracking": true, + "Locked": false + }, + + "HttpsOnlyMode": "enabled", + + "SearchEngines": { + "PreventInstalls": false, + "Remove": ["Google", "Bing", "Amazon.com", "eBay"], + "Default": "DuckDuckGo" + }, + + "Homepage": { + "StartPage": "none" + }, + + "NewTabPage": false, + + "PopupBlocking": { + "Default": true, + "Locked": false + }, + + "UserMessaging": { + "WhatsNew": false, + "ExtensionRecommendations": false, + "FeatureRecommendations": false, + "UrlbarInterventions": false, + "SkipOnboarding": true, + "MoreFromMozilla": false, + "FirefoxLabs": false, + "Locked": false + }, + + "FirefoxHome": { + "Search": true, + "TopSites": false, + "SponsoredTopSites": false, + "Highlights": false, + "Pocket": false, + "SponsoredPocket": false, + "Snippets": false, + "Locked": false + }, + + "DisableFeedbackCommands": true, + "DisableSetDesktopBackground": false, + "DisableDeveloperTools": false, + "AutofillCreditCardEnabled": false, + "AutofillAddressEnabled": false, + + "Permissions": { + "Camera": { + "BlockNewRequests": false, + "Locked": false + }, + "Microphone": { + "BlockNewRequests": false, + "Locked": false + }, + "Location": { + "BlockNewRequests": true, + "Locked": false + }, + "Notifications": { + "BlockNewRequests": false, + "Locked": false + }, + "Autoplay": { + "Default": "block-audio-video", + "Locked": false + } + }, + + "SanitizeOnShutdown": { + "Cache": false, + "Cookies": false, + "Downloads": false, + "FormData": true, + "History": false, + "Sessions": false, + "SiteSettings": false, + "OfflineApps": false, + "Locked": false + }, + + "TranslateEnabled": true, + + "ExtensionUpdate": true, + + "Certificates": { + "ImportEnterpriseRoots": false + }, + + "ExtensionSettings": { + "*": { + "installation_mode": "allowed", + "blocked_install_message": "This extension is not allowed by BearBrowser policy." + }, + "uBlock0@raymondhill.net": { + "installation_mode": "normal_installed", + "install_url": "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi" + } + }, + + "EncryptedMediaExtensions": { + "Enabled": false, + "Locked": false + } } } diff --git a/settings/profiles/human-secure/user.js b/settings/profiles/human-secure/user.js new file mode 100644 index 0000000..546b4a3 --- /dev/null +++ b/settings/profiles/human-secure/user.js @@ -0,0 +1,200 @@ +// BearBrowser human-secure profile preferences. +// Applied on top of LibreWolf defaults. Values here take precedence. +// Do not add agent-runtime concerns to this file — see profiles/agent-runtime/user.js. + +// ── DNS-over-HTTPS ──────────────────────────────────────────────────────────── +// Mode 3 = strict DoH only; refuse to fall back to plaintext DNS. +user_pref("network.trr.mode", 3); +user_pref("network.trr.uri", "https://1.1.1.1/dns-query"); +user_pref("network.trr.bootstrapAddress", "1.1.1.1"); +user_pref("network.trr.confirmationNS", "skip"); + +// ── Google Safe Browsing — belt-and-suspenders removal ─────────────────────── +// LibreWolf strips this at build time; these prefs ensure it stays off even if +// an upstream update re-enables it. +user_pref("browser.safebrowsing.malware.enabled", false); +user_pref("browser.safebrowsing.phishing.enabled", false); +user_pref("browser.safebrowsing.blockedURIs.enabled", false); +user_pref("browser.safebrowsing.downloads.enabled", false); +user_pref("browser.safebrowsing.downloads.remote.enabled", false); +user_pref("browser.safebrowsing.provider.google4.updateURL", ""); +user_pref("browser.safebrowsing.provider.google4.gethashURL", ""); +user_pref("browser.safebrowsing.provider.google.updateURL", ""); +user_pref("browser.safebrowsing.provider.google.gethashURL", ""); +user_pref("browser.safebrowsing.provider.mozilla.updateURL", ""); +user_pref("browser.safebrowsing.provider.mozilla.gethashURL", ""); + +// ── Fingerprinting resistance ───────────────────────────────────────────────── +// LibreWolf enables privacy.resistFingerprinting by default; reinforce here. +user_pref("privacy.resistFingerprinting", true); +user_pref("privacy.resistFingerprinting.letterboxing", true); +user_pref("webgl.disabled", false); +user_pref("webgl.enable-webgl2", true); +// Randomize canvas — site-specific exceptions are still possible via permissions. +user_pref("privacy.resistFingerprinting.randomDataOnCanvasExtract", true); + +// ── Tracker and content blocking ───────────────────────────────────────────── +user_pref("privacy.trackingprotection.enabled", true); +user_pref("privacy.trackingprotection.socialtracking.enabled", true); +user_pref("privacy.trackingprotection.cryptomining.enabled", true); +user_pref("privacy.trackingprotection.fingerprinting.enabled", true); +user_pref("privacy.trackingprotection.emailtracking.enabled", true); +user_pref("browser.contentblocking.category", "strict"); + +// ── Cookie isolation (dFPI — Dynamic First-Party Isolation) ────────────────── +// Firefox 86+ dFPI replaces the older firstparty.isolate flag. +user_pref("privacy.partition.network_state", true); +user_pref("privacy.partition.serviceWorkers", true); +user_pref("privacy.partition.bloburl_by_registrable_domain", true); +// Third-party cookies blocked entirely. +user_pref("network.cookie.cookieBehavior", 5); +user_pref("network.cookie.sameSite.noneRequiresSecure", true); + +// ── WebRTC IP leak protection ──────────────────────────────────────────────── +// Do not expose local IP addresses through WebRTC ICE candidates. +user_pref("media.peerconnection.ice.default_address_only", true); +user_pref("media.peerconnection.ice.no_host", true); + +// ── Telemetry — belt-and-suspenders ────────────────────────────────────────── +// LibreWolf disables most of this at build time; keep them off via prefs too. +user_pref("toolkit.telemetry.enabled", false); +user_pref("toolkit.telemetry.unified", false); +user_pref("toolkit.telemetry.server", ""); +user_pref("datareporting.healthreport.uploadEnabled", false); +user_pref("datareporting.policy.dataSubmissionEnabled", false); +user_pref("browser.ping-centre.telemetry", false); +user_pref("browser.newtabpage.activity-stream.feeds.telemetry", false); +user_pref("browser.newtabpage.activity-stream.telemetry", false); + +// ── Search engine ───────────────────────────────────────────────────────────── +// Remove Google as a preset and default to a privacy-respecting engine. +// The actual engine list is managed via policies.json; these prefs suppress +// Google re-appearing after updates. +user_pref("browser.search.suggest.enabled", false); +user_pref("browser.urlbar.suggest.searches", false); +user_pref("browser.search.separatePrivateDefault", true); +user_pref("browser.search.separatePrivateDefault.ui.enabled", true); + +// ── Location and sensors ────────────────────────────────────────────────────── +user_pref("geo.enabled", false); +user_pref("geo.provider.network.url", ""); +user_pref("device.sensors.enabled", false); + +// ── Form and login data ─────────────────────────────────────────────────────── +user_pref("signon.rememberSignons", false); +user_pref("signon.autofillForms", false); +user_pref("browser.formfill.enable", false); +user_pref("extensions.formautofill.creditCards.enabled", false); + +// ── Network ─────────────────────────────────────────────────────────────────── +user_pref("network.http.speculative-parallel-limit", 0); +user_pref("network.prefetch-next", false); +user_pref("network.predictor.enabled", false); +user_pref("network.dns.disablePrefetch", true); +user_pref("network.dns.disablePrefetchFromHTTPS", true); + +// ── HTTPS-Only mode ─────────────────────────────────────────────────────────── +user_pref("dom.security.https_only_mode", true); +user_pref("dom.security.https_only_mode_send_http_background_request", false); + +// ── Media autoplay ──────────────────────────────────────────────────────────── +user_pref("media.autoplay.default", 5); +user_pref("media.autoplay.blocking_policy", 2); + +// ── Certificate / TLS hardening ─────────────────────────────────────────────── +// Refuse TLS handshakes that use legacy (unsafe) renegotiation. Sites that +// require it are either misconfigured or actively downgrading security. +user_pref("security.ssl.require_safe_negotiation", true); +user_pref("security.ssl.treat_unsafe_negotiation_as_broken", true); +// TLS 1.2 minimum (value 3 = TLS 1.2; Firefox 78+ ships with this default but +// this pref makes it explicit and immune to policy resets). +user_pref("security.tls.version.min", 3); +// OCSP: check certificate revocation status on every TLS connection. +// require=false avoids hard failures when an OCSP responder is unavailable +// (soft-fail is standard practice; hard-fail breaks too many sites on flaky +// networks while providing marginal extra security over CRLite). +user_pref("security.OCSP.enabled", 1); +user_pref("security.OCSP.require", false); +// Enforce built-in certificate pins strictly (level 2). Prevents MITM against +// pinned properties even if a rogue CA is in the trust store. +user_pref("security.cert_pinning.enforcement_level", 2); +// CRLite mode 2: use the pre-downloaded CRL bloom filter for revocation checks. +// Faster and more reliable than OCSP alone; doesn't leak visited sites to OCSP +// responders. +user_pref("security.pki.crlite_mode", 2); + +// ── Media / hardware fingerprinting ────────────────────────────────────────── +// Suppress video decode statistics exposed via HTMLVideoElement.getVideoPlaybackQuality. +// These stats vary by GPU/driver and form a fingerprinting vector. +user_pref("media.video_stats.enabled", false); +// Prevent sites from enumerating cameras and microphones without an explicit +// permission grant. The devices are still fully usable once the user approves. +user_pref("media.navigator.enabled", false); +// Disable DRM (Encrypted Media Extensions) by default. Users who need Widevine +// for a specific site can re-enable via about:preferences or a site permission. +// Widevine involves a closed-source binary blob with unclear data practices. +user_pref("media.eme.enabled", false); +// Disable the GMP (Gecko Media Plugin) provider that auto-downloads Widevine. +// Without EME enabled this is redundant, but belt-and-suspenders. +user_pref("media.gmp-provider.enabled", false); +// MSE (Media Source Extensions) left enabled — disabling breaks YouTube, +// Twitch, and virtually every major streaming site. +user_pref("dom.media.mediasource.enabled", true); + +// ── Site isolation (Fission) ────────────────────────────────────────────────── +// Fission puts each site origin in its own OS process, preventing cross-site +// data leaks via Spectre-class side-channels. Enabled by default in Firefox 95+ +// but explicit here so a downstream patch can't silently roll it back. +user_pref("fission.autostart", true); +// Ensure privileged Mozilla content (about:*, AMO, etc.) gets its own separate +// process, isolated from regular web content. +user_pref("browser.tabs.remote.separatePrivilegedMozillaWebContentProcess", true); + +// ── Push / notification background connection ───────────────────────────────── +// Disable the persistent WebPush connection to Mozilla's push service. Push +// notifications still work when the user explicitly grants permission per-site, +// but the background TCP connection is severed at rest. Reduces idle network +// fingerprinting surface and eliminates a persistent connection to Mozilla infra. +user_pref("dom.push.connection.enabled", false); + +// ── Referer / network anti-fingerprinting ───────────────────────────────────── +// RFP already strips most referer leakage, but these prefs apply independently +// (e.g. for users who disable RFP for usability) and make intent explicit. +// 2 = send referer header (required for many sites); defaultPolicy controls +// what is sent. strict-origin-when-cross-origin (2) = send full URL same-origin, +// origin-only cross-origin, nothing cross-scheme. Matches Chrome's default. +user_pref("network.http.sendRefererHeader", 2); +user_pref("network.http.referer.defaultPolicy", 2); +user_pref("network.http.referer.defaultPolicy.pbmode", 2); +// Show raw punycode for IDN domains in the address bar. Prevents IDN homograph +// attacks where visually identical Unicode characters substitute for ASCII +// (e.g. аpple.com with Cyrillic а). +user_pref("network.IDN_show_punycode", true); + +// ── Crash reporting (belt-and-suspenders) ───────────────────────────────────── +// LibreWolf disables crash reporting at build time. These prefs ensure any +// upstream patch or a stock Firefox build never silently re-enables it. +user_pref("breakpad.reportURL", ""); +user_pref("browser.tabs.crashReporting.sendReport", false); +user_pref("browser.crashReports.unsubmittedCheck.autoSubmit2", false); + +// ── Experiments / studies (belt-and-suspenders) ─────────────────────────────── +// Belt-and-suspenders alongside DisableFirefoxStudies in policies.json. +// These prefs cover older Firefox experiment infrastructure not gated by that +// policy key. +user_pref("experiments.activeExperiment", false); +user_pref("experiments.enabled", false); +user_pref("experiments.supported", false); +user_pref("network.allow-experiments", false); + +// ── Misc DOM hardening ──────────────────────────────────────────────────────── +// Prevent scripts from programmatically closing browser windows they did not +// open. Commonly abused by pop-under ad networks. +user_pref("dom.allow_scripts_to_close_windows", false); +// Honour X-Frame-Options headers. Prevents clickjacking by ensuring the browser +// respects sites that declare they must not be framed. +user_pref("browser.xfocontent", true); +// Allow links that request a new window (target=_blank etc.) to open in a tab +// instead of a separate window. 0 = no restriction; new windows become tabs. +// Improves usability without any privacy regression. +user_pref("browser.link.open_newwindow.restriction", 0); From d6498d4fb438fa81dc09bd2bf07c27d76928677a Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:59:18 -0400 Subject: [PATCH 02/66] Add source build packaging and fix macOS build tooling Source build: - bearbrowser-package-source-build.sh: packages obj-*/dist/BearBrowser.app from a completed mach build into a settings-injected, ad-hoc-signed BearBrowser.app ready for development use. Auto-detects workspace, injects user.js and policies.json, writes Info.plist, signs depth-first. macOS native launcher: - BearBrowserWebKitLauncher.m: updated launcher implementation - BearBrowser-start.html: updated start page - install-macos-app-launcher.sh: updated installer - repair-macos-app-launcher.sh: updated repair script Packaging: - Info.plist.template: completed with URL schemes and document types - package-linux-rpm.sh: updated RPM packaging --- native/macos/BearBrowser-start.html | 235 +- native/macos/BearBrowserWebKitLauncher.m | 4299 +++++++++++++++++-- packaging/macos/Info.plist.template | 38 + scripts/bearbrowser-package-source-build.sh | 201 + scripts/install-macos-app-launcher.sh | 28 + scripts/package-linux-rpm.sh | 19 +- scripts/repair-macos-app-launcher.sh | 26 +- 7 files changed, 4503 insertions(+), 343 deletions(-) create mode 100755 scripts/bearbrowser-package-source-build.sh diff --git a/native/macos/BearBrowser-start.html b/native/macos/BearBrowser-start.html index 552ebd0..2ab22a5 100644 --- a/native/macos/BearBrowser-start.html +++ b/native/macos/BearBrowser-start.html @@ -5,25 +5,230 @@ BearBrowser -
-
🐻
-

BearBrowser

-

Native BearBrowser bootstrap shell is running. The Dock process and application identity are BearBrowser.

-

The governed feature plane is now active: local provenance events, policy-visible action records, and a sidecar status surface.

+ +
+
🐻
+ BearBrowser +
+ +
+ Provenance active + Policy governed + No Google telemetry + Fingerprint shield +
+ +
+ 🔍 + + ⌘L +
+
-
ProvenanceLaunch and navigation events are recorded locally with redaction.
-
PolicyAgentic actions are proposed before authority is granted.
+ +
🦆
+ DuckDuckGo +
+ +
🔍
+ Kagi +
+ +
🐙
+ BearBrowser +
+ +
🟠
+ Hacker News +
+
+ + -
Use the Sidecar Status button to inspect local governance state.
-
+ diff --git a/native/macos/BearBrowserWebKitLauncher.m b/native/macos/BearBrowserWebKitLauncher.m index 5874415..9cb1827 100644 --- a/native/macos/BearBrowserWebKitLauncher.m +++ b/native/macos/BearBrowserWebKitLauncher.m @@ -1,396 +1,4045 @@ #import #import +#import +#import +#import +#import +#import +#include +#include +#include -static NSString *BBSupportDir(void) { - return [NSHomeDirectory() stringByAppendingPathComponent:@"Library/Application Support/BearBrowser"]; +// Forward declarations needed by classes defined before the support helpers +static NSString *BBSupportDir(void); +static NSString *BBLogDir(void); + +// ── BBFontSchemeHandler ─────────────────────────────────────────────────────── +// Serves bundled woff2 fonts via bbfont:// scheme so pages never hit Google Fonts CDN. +@interface BBFontSchemeHandler : NSObject +@property(strong) NSString *fontsDir; +@end +@implementation BBFontSchemeHandler +- (instancetype)init { + self=[super init]; + NSString *bundle=[[NSBundle mainBundle] resourcePath]; + _fontsDir=[bundle stringByAppendingPathComponent:@"fonts/woff2"]; + return self; +} +- (void)webView:(WKWebView *)wv startURLSchemeTask:(id)task { + NSURL *url=task.request.URL; + // bbfont://fonts/ + NSString *filename=url.path.lastPathComponent; + NSString *path=[self.fontsDir stringByAppendingPathComponent:filename]; + NSData *data=[NSData dataWithContentsOfFile:path]; + if (!data) { + [task didFailWithError:[NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorFileDoesNotExist userInfo:nil]]; + return; + } + NSURLResponse *resp=[[NSURLResponse alloc]initWithURL:url MIMEType:@"font/woff2" + expectedContentLength:data.length textEncodingName:nil]; + [task didReceiveResponse:resp]; + [task didReceiveData:data]; + [task didFinish]; } +- (void)webView:(WKWebView *)wv stopURLSchemeTask:(id)task {} +@end + +// ── BBContentBlocker ────────────────────────────────────────────────────────── +// Compiles and caches WKContentRuleList for tracker/ad blocking. +@interface BBContentBlocker : NSObject ++ (void)loadRulesInto:(WKWebViewConfiguration *)config completion:(void(^)(void))done; +@end +@implementation BBContentBlocker -static NSString *BBLogDir(void) { - return [NSHomeDirectory() stringByAppendingPathComponent:@"Library/Logs/BearBrowser"]; +// Baseline tracker/ad rules — covers the highest-traffic domains. +// Full list updated via scripts/update-content-rules.sh → compiled JSON cached to disk. ++ (NSString *)baselineRulesJSON { + // Format: WKContentRuleList declarative JSON. + // Block trackers at the network layer before any request fires. + static NSArray *trackerDomains = nil; + if (!trackerDomains) trackerDomains = @[ + // Analytics & tracking + @"google-analytics\\.com", @"googletagmanager\\.com", @"googletagservices\\.com", + @"doubleclick\\.net", @"googlesyndication\\.com", @"adservice\\.google\\.com", + @"facebook\\.com/tr", @"connect\\.facebook\\.net", @"analytics\\.twitter\\.com", + @"t\\.co/[0-9]", @"static\\.ads-twitter\\.com", + @"hotjar\\.com", @"fullstory\\.com", @"logrocket\\.com", @"smartlook\\.com", + @"mixpanel\\.com", @"amplitude\\.com/api", @"segment\\.io", @"segment\\.com/analytics", + @"heap\\.io", @"heapanalytics\\.com", + @"newrelic\\.com/browser", @"nr-data\\.net", + @"intercom\\.io/api", @"intercomcdn\\.com", + @"crisp\\.chat/client", @"widget\\.intercom\\.io", + // Ad networks + @"ads\\.linkedin\\.com", @"platform\\.linkedin\\.com/in\\.js", + @"snap\\.licdn\\.com", @"px\\.ads\\.linkedin\\.com", + @"bing\\.com/bat", @"bat\\.bing\\.com", + @"amazon-adsystem\\.com", @"aax-us-east\\.amazon-adsystem\\.com", + @"rubiconproject\\.com", @"openx\\.net", @"pubmatic\\.com", + @"casalemedia\\.com", @"criteo\\.com", @"criteo\\.net", + @"outbrain\\.com", @"taboola\\.com", @"revcontent\\.com", + @"moatads\\.com", @"adnxs\\.com", @"appnexus\\.com", + @"bidswitch\\.net", @"ssp\\.yahoo\\.com", @"gemini\\.yahoo\\.com", + // Font CDNs (served locally instead) + @"fonts\\.googleapis\\.com", @"fonts\\.gstatic\\.com", + @"use\\.typekit\\.net", @"p\\.typekit\\.net", + // Fingerprinting & session replay + @"fingerprintjs\\.com", @"fp\\.clarity\\.ms", @"clarity\\.ms/tag", + @"mouseflow\\.com", @"inspectlet\\.com", @"sessioncam\\.com", + // Social widgets (privacy leak even without interaction) + @"platform\\.twitter\\.com/widgets", @"platform\\.instagram\\.com", + @"staticxx\\.facebook\\.com", @"www\\.facebook\\.com/plugins", + // Data brokers / identity resolution + @"quantserve\\.com", @"scorecardresearch\\.com", @"comscore\\.com", + @"bluekai\\.com", @"turn\\.com", @"mediamath\\.com", + @"adsymptotic\\.com", @"adsafeprotected\\.com", + ]; + + NSMutableArray *rules=[NSMutableArray array]; + // Block all tracker domains + for (NSString *pattern in trackerDomains) { + [rules addObject:@{ + @"trigger": @{@"url-filter": pattern, @"load-type": @[@"third-party"]}, + @"action": @{@"type": @"block"} + }]; + } + // Block font CDNs entirely (we serve locally) + [rules addObject:@{ + @"trigger": @{@"url-filter": @"fonts\\.googleapis\\.com|fonts\\.gstatic\\.com|use\\.typekit\\.net"}, + @"action": @{@"type": @"block"} + }]; + // Block known tracking pixels (1x1 images) + [rules addObject:@{ + @"trigger": @{@"url-filter": @".*", @"resource-type": @[@"image"], + @"url-filter-is-case-sensitive": @NO, + @"load-type": @[@"third-party"]}, + @"action": @{@"type": @"css-display-none", @"selector": @"img[width='1'][height='1'],img[src*='pixel'],img[src*='beacon'],img[src*='tracking']"} + }]; + NSData *json=[NSJSONSerialization dataWithJSONObject:rules options:0 error:nil]; + return [[NSString alloc]initWithData:json encoding:NSUTF8StringEncoding]; } -static NSString *BBProvenancePath(void) { - return [[BBSupportDir() stringByAppendingPathComponent:@"provenance"] stringByAppendingPathComponent:@"events.jsonl"]; ++ (void)loadRulesInto:(WKWebViewConfiguration *)config completion:(void(^)(void))done { + // Check for compiled rules on disk (put there by update-content-rules.sh) + NSString *compiledPath=[[BBSupportDir() stringByAppendingPathComponent:@"content-rules"] stringByAppendingPathComponent:@"rules.json"]; + NSString *rulesJSON=([NSFileManager.defaultManager fileExistsAtPath:compiledPath]) + ? [NSString stringWithContentsOfFile:compiledPath encoding:NSUTF8StringEncoding error:nil] + : nil; + if (!rulesJSON.length) rulesJSON=[self baselineRulesJSON]; + + WKContentRuleListStore *store=[WKContentRuleListStore defaultStore]; + [store compileContentRuleListForIdentifier:@"bb-baseline" + encodedContentRuleList:rulesJSON + completionHandler:^(WKContentRuleList *list, NSError *err) { + if (list) [config.userContentController addContentRuleList:list]; + if (err) NSLog(@"[BBContentBlocker] compile error: %@", err.localizedDescription); + if (done) done(); + }]; } +@end + +// ── BBVoice ─────────────────────────────────────────────────────────────────── +// Read-aloud with voice tuned between Gemini Ursa / ChatGPT Sol (female) +// and Australian-inflected natural male. Falls back gracefully on older macOS. +@interface BBVoice : NSObject +@property(strong) AVSpeechSynthesizer *synth; +@property(assign) BOOL speaking; ++ (instancetype)shared; +- (void)readPage:(WKWebView *)wv; +- (void)stop; +@end +@implementation BBVoice ++ (instancetype)shared { static BBVoice *s; static dispatch_once_t t; dispatch_once(&t,^{s=[[self alloc]init];}); return s; } +- (instancetype)init { self=[super init]; _synth=[[AVSpeechSynthesizer alloc]init]; _synth.delegate=self; return self; } -static NSString *BBPolicyPath(void) { - return [[BBSupportDir() stringByAppendingPathComponent:@"policy"] stringByAppendingPathComponent:@"actions.jsonl"]; +// Preferred voice identifiers in priority order. +// Female: Karen Enhanced (AU) → Zoe Enhanced → Samantha Enhanced → Samantha +// Male: Lee Enhanced (AU) → Daniel Enhanced (UK) → Alex ++ (AVSpeechSynthesisVoice *)preferredVoiceForGender:(AVSpeechSynthesisVoiceGender)gender { + NSArray *femaleIds=@[ + @"com.apple.voice.enhanced.en-AU.Karen", + @"com.apple.voice.premium.en-AU.Karen", + @"com.apple.ttsbundle.Karen-premium", + @"com.apple.voice.enhanced.en-US.Zoe", + @"com.apple.voice.enhanced.en-US.Samantha", + @"com.apple.ttsbundle.Samantha-premium", + ]; + NSArray *maleIds=@[ + @"com.apple.voice.enhanced.en-AU.Lee", + @"com.apple.voice.premium.en-AU.Lee", + @"com.apple.ttsbundle.Lee-premium", + @"com.apple.voice.enhanced.en-GB.Daniel", + @"com.apple.ttsbundle.Alex-compact", + ]; + NSArray *candidates=(gender==AVSpeechSynthesisVoiceGenderFemale)?femaleIds:maleIds; + for (NSString *vid in candidates) { + AVSpeechSynthesisVoice *v=[AVSpeechSynthesisVoice voiceWithIdentifier:vid]; + if (v) return v; + } + // Final fallback: pick first system voice matching language + for (AVSpeechSynthesisVoice *v in [AVSpeechSynthesisVoice speechVoices]) { + if ([v.language hasPrefix:@"en"] && v.gender==gender) return v; + } + return nil; } -static NSString *BBMemoryPath(void) { - return [[BBSupportDir() stringByAppendingPathComponent:@"memory"] stringByAppendingPathComponent:@"candidates.jsonl"]; +- (void)readPage:(WKWebView *)wv { + if (self.speaking) { [self stop]; return; } + [wv evaluateJavaScript: + @"(function(){" + @"var sel=window.getSelection&&window.getSelection().toString().trim();" + @"if(sel&&sel.length>0)return sel;" + @"var a=document.querySelector('article')||document.querySelector('main')||document.body;" + @"return (a?a.innerText:'').replace(/\\s+/g,' ').trim().slice(0,8000);" + @"})()" + completionHandler:^(id r,NSError *e){ + if(e||![r isKindOfClass:[NSString class]]||![(NSString*)r length]) return; + AVSpeechUtterance *u=[AVSpeechUtterance speechUtteranceWithString:(NSString*)r]; + u.voice=[BBVoice preferredVoiceForGender:AVSpeechSynthesisVoiceGenderFemale]; + u.rate=0.52f; // slightly slower than default (0.5) for clarity — between Ursa and Sol pacing + u.pitchMultiplier=1.05f; + u.volume=0.95f; + self.speaking=YES; + [self.synth speakUtterance:u]; + }]; } +- (void)stop { [self.synth stopSpeakingAtBoundary:AVSpeechBoundaryImmediate]; self.speaking=NO; } +- (void)speechSynthesizer:(AVSpeechSynthesizer *)s didFinishSpeechUtterance:(AVSpeechUtterance *)u { self.speaking=NO; } +@end +// ── Support helpers ─────────────────────────────────────────────────────────── +static NSString *BBSupportDir(void) { return [NSHomeDirectory() stringByAppendingPathComponent:@"Library/Application Support/BearBrowser"]; } +static NSString *BBLogDir(void) { return [NSHomeDirectory() stringByAppendingPathComponent:@"Library/Logs/BearBrowser"]; } +static NSString *BBProvenancePath(void) { return [[BBSupportDir() stringByAppendingPathComponent:@"provenance"] stringByAppendingPathComponent:@"events.jsonl"]; } +static NSString *BBPolicyPath(void) { return [[BBSupportDir() stringByAppendingPathComponent:@"policy"] stringByAppendingPathComponent:@"actions.jsonl"]; } +static NSString *BBMemoryPath(void) { return [[BBSupportDir() stringByAppendingPathComponent:@"memory"] stringByAppendingPathComponent:@"candidates.jsonl"]; } static NSString *BBTimestamp(void) { - NSDateFormatter *fmt = [[NSDateFormatter alloc] init]; - fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; - fmt.timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0]; - fmt.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss'Z'"; - return [fmt stringFromDate:[NSDate date]]; + NSDateFormatter *f=[[NSDateFormatter alloc]init]; + f.locale=[NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + f.timeZone=[NSTimeZone timeZoneForSecondsFromGMT:0]; + f.dateFormat=@"yyyy-MM-dd'T'HH:mm:ss'Z'"; + return [f stringFromDate:[NSDate date]]; +} +static NSString *BBRandomHex(NSUInteger n) { + NSMutableString *s=[NSMutableString stringWithCapacity:n*2]; + for (NSUInteger i=0;i":text?:@"", + @"policy":@{@"decision":@"hold",@"decisionId":[@"local-" stringByAppendingString:BBRandomHex(8)],@"mode":@"local-default",@"reason":@"Candidates require explicit commit or reject."} + })); + BBEmitEvent(@"memory.candidate_created",@"hold",@"Held memory candidate.",@{@"memoryId":memId,@"url":srcURL?:@""}); } -static NSString *BBRandomHex(NSUInteger bytes) { - NSMutableString *out = [NSMutableString stringWithCapacity:bytes * 2]; - for (NSUInteger i = 0; i < bytes; i++) { - uint8_t value = (uint8_t)arc4random_uniform(256); - [out appendFormat:@"%02x", value]; +// ── Layout constants ────────────────────────────────────────────────────────── +static const CGFloat kToolbarH = 52.0; +static const CGFloat kTabBarH = 36.0; +static const CGFloat kFindBarH = 44.0; +static const CGFloat kBMBarH = 30.0; +static const CGFloat kDLPanelW = 280.0; +static const CGFloat kTabMaxW = 220.0; +static const CGFloat kTabMinW = 80.0; + +// ── BBTab ───────────────────────────────────────────────────────────────────── +@interface BBTab : NSObject +@property(strong) WKWebView *webView; +@property(copy) NSString *title; +@property(strong) NSImage *favicon; +@property(assign) BOOL isLoading; +@property(assign) BOOL isPrivate; +@end +@implementation BBTab +- (instancetype)init { self=[super init]; _title=@"New Tab"; return self; } +@end + +// ── BBTabItemView ───────────────────────────────────────────────────────────── +@protocol BBTabItemDelegate +- (void)tabItemDidSelect:(NSInteger)index; +- (void)tabItemDidClose:(NSInteger)index; +@end + +@interface BBTabItemView : NSView +@property(assign) NSInteger index; +@property(nonatomic,assign) BOOL isActive; +@property(nonatomic,assign) BOOL isHovered; +@property(nonatomic,assign) BOOL isPrivate; +@property(strong) NSImageView *faviconView; +@property(strong) NSTextField *titleLabel; +@property(strong) NSButton *closeButton; +@property(weak) id delegate; +- (void)setTabTitle:(NSString *)title favicon:(NSImage *)favicon loading:(BOOL)loading; +@end + +@implementation BBTabItemView +- (instancetype)initWithFrame:(NSRect)f index:(NSInteger)idx delegate:(id)d { + self=[super initWithFrame:f]; _index=idx; _delegate=d; + [self addTrackingArea:[[NSTrackingArea alloc]initWithRect:self.bounds + options:NSTrackingMouseEnteredAndExited|NSTrackingActiveInKeyWindow|NSTrackingInVisibleRect + owner:self userInfo:nil]]; + // Favicon (16×16) + _faviconView=[[NSImageView alloc]initWithFrame:NSMakeRect(8,10,16,16)]; + _faviconView.imageScaling=NSImageScaleProportionallyUpOrDown; + [self addSubview:_faviconView]; + // Title + _titleLabel=[[NSTextField alloc]initWithFrame:NSMakeRect(28,8,f.size.width-54,20)]; + _titleLabel.autoresizingMask=NSViewWidthSizable; + _titleLabel.bordered=NO; _titleLabel.editable=NO; _titleLabel.selectable=NO; + _titleLabel.backgroundColor=[NSColor clearColor]; + _titleLabel.font=[NSFont systemFontOfSize:12 weight:NSFontWeightRegular]; + _titleLabel.lineBreakMode=NSLineBreakByTruncatingTail; + [self addSubview:_titleLabel]; + // Close button + _closeButton=[[NSButton alloc]initWithFrame:NSMakeRect(f.size.width-26,9,18,18)]; + _closeButton.autoresizingMask=NSViewMinXMargin; + _closeButton.bezelStyle=NSBezelStyleCircular; _closeButton.bordered=NO; + NSImage *xi=[NSImage imageWithSystemSymbolName:@"xmark" accessibilityDescription:@"Close Tab"]; + xi=[xi imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:8 weight:NSFontWeightMedium]]; + [xi setTemplate:YES]; _closeButton.image=xi; _closeButton.imagePosition=NSImageOnly; + _closeButton.target=self; _closeButton.action=@selector(closeTab:); _closeButton.toolTip=@"Close Tab"; + [self addSubview:_closeButton]; + return self; +} +- (void)setTabTitle:(NSString *)title favicon:(NSImage *)favicon loading:(BOOL)loading { + self.titleLabel.stringValue=title.length?title:@"New Tab"; + self.titleLabel.textColor=self.isActive?[NSColor labelColor]:[NSColor secondaryLabelColor]; + if (loading) { + NSImage *spinner=[NSImage imageWithSystemSymbolName:@"arrow.2.circlepath" accessibilityDescription:@"Loading"]; + self.faviconView.image=spinner; + } else if (favicon) { + self.faviconView.image=favicon; + } else { + NSImage *globe=[NSImage imageWithSystemSymbolName:@"globe" accessibilityDescription:@"Page"]; + [globe setTemplate:YES]; self.faviconView.image=globe; + } + if (self.isPrivate) { + NSImage *priv=[NSImage imageWithSystemSymbolName:@"eyeglasses" accessibilityDescription:@"Private"]; + [priv setTemplate:YES]; self.faviconView.image=priv; + } +} +- (void)setIsActive:(BOOL)active { + _isActive=active; [self setNeedsDisplay:YES]; + self.titleLabel.textColor=active?[NSColor labelColor]:[NSColor secondaryLabelColor]; + self.titleLabel.font=[NSFont systemFontOfSize:12 weight:active?NSFontWeightMedium:NSFontWeightRegular]; +} +- (void)drawRect:(NSRect)r { + if (self.isActive) { + [[NSColor windowBackgroundColor] setFill]; + [[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(self.bounds,1,1) xRadius:7 yRadius:7] fill]; + [[NSColor separatorColor] setStroke]; + NSBezierPath *p=[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(self.bounds,1,1) xRadius:7 yRadius:7]; + p.lineWidth=0.5; [p stroke]; + if (self.isPrivate) { + [[NSColor colorWithRed:0.2 green:0.1 blue:0.3 alpha:0.12] setFill]; + [[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(self.bounds,1,1) xRadius:7 yRadius:7] fill]; + } + } else if (self.isHovered) { + [[NSColor colorWithWhite:0.5 alpha:0.12] setFill]; + [[NSBezierPath bezierPathWithRoundedRect:NSInsetRect(self.bounds,1,1) xRadius:7 yRadius:7] fill]; } - return out; } +- (void)mouseEntered:(NSEvent *)e { self.isHovered=YES; [self setNeedsDisplay:YES]; } +- (void)mouseExited:(NSEvent *)e { self.isHovered=NO; [self setNeedsDisplay:YES]; } +- (void)mouseDown:(NSEvent *)e { [self.delegate tabItemDidSelect:self.index]; } +- (void)closeTab:(id)s { [self.delegate tabItemDidClose:self.index]; } +@end + +// ── BBChromeBGView ───────────────────────────────────────────────────────────── +// Background fill that resolves at draw time — never set CGColor at init time +// since NSColor.windowBackgroundColor.CGColor is nil before the view has a window. +@interface BBChromeBGView : NSView @end +@implementation BBChromeBGView +- (void)drawRect:(NSRect)r { [[NSColor windowBackgroundColor] setFill]; NSRectFill(r); } +@end -static NSString *BBShellQuote(NSString *value) { - NSString *safe = [value stringByReplacingOccurrencesOfString:@"'" withString:@"'\\''"]; - return [NSString stringWithFormat:@"'%@'", safe]; +// ── BBTabBarView ────────────────────────────────────────────────────────────── +// NSVisualEffectView with Sidebar material (NOT Titlebar — no click interception). +@interface BBTabBarView : NSVisualEffectView +@property(strong) NSMutableArray *items; +@property(assign) NSInteger activeIndex; +@property(strong) NSButton *addTabButton; +@property(weak) id outerDelegate; +- (void)reloadWithTabs:(NSArray *)tabs activeIndex:(NSInteger)active; +@end +@implementation BBTabBarView +- (instancetype)initWithFrame:(NSRect)f delegate:(id)d { + self=[super initWithFrame:f]; + self.material=NSVisualEffectMaterialSidebar; + self.blendingMode=NSVisualEffectBlendingModeWithinWindow; + self.state=NSVisualEffectStateActive; + NSBox *sep=[[NSBox alloc]initWithFrame:NSMakeRect(0,0,f.size.width,1)]; + sep.autoresizingMask=NSViewWidthSizable; sep.boxType=NSBoxSeparator; [self addSubview:sep]; + _items=[NSMutableArray array]; _outerDelegate=d; + _addTabButton=[[NSButton alloc]initWithFrame:NSMakeRect(f.size.width-34,4,28,28)]; + _addTabButton.autoresizingMask=NSViewMinXMargin; + NSImage *pi=[NSImage imageWithSystemSymbolName:@"plus" accessibilityDescription:@"New Tab"]; + pi=[pi imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:12 weight:NSFontWeightMedium]]; + [pi setTemplate:YES]; _addTabButton.image=pi; _addTabButton.imagePosition=NSImageOnly; + _addTabButton.bezelStyle=NSBezelStyleToolbar; _addTabButton.bordered=NO; + _addTabButton.toolTip=@"New Tab (⌘T)"; [self addSubview:_addTabButton]; + return self; } +- (void)reloadWithTabs:(NSArray *)tabs activeIndex:(NSInteger)active { + for (BBTabItemView *v in self.items) [v removeFromSuperview]; + [self.items removeAllObjects]; + self.activeIndex=active; + NSInteger count=tabs.count; if (!count) return; + CGFloat avail=self.bounds.size.width-40; + CGFloat tabW=MIN(kTabMaxW,MAX(kTabMinW,floor(avail/count))); + for (NSInteger i=0;i *items; ++ (instancetype)shared; +- (void)addTitle:(NSString *)t url:(NSString *)u; +- (void)removeAtIndex:(NSInteger)i; +- (BOOL)isBookmarked:(NSString *)u; +@end +@implementation BBBookmarksStore ++ (instancetype)shared { static BBBookmarksStore *s; static dispatch_once_t o; dispatch_once(&o,^{s=[[self alloc]init];}); return s; } +- (instancetype)init { + self=[super init]; _items=[NSMutableArray array]; + NSString *path=[BBSupportDir() stringByAppendingPathComponent:@"bookmarks.json"]; + NSData *d=[NSData dataWithContentsOfFile:path]; + if (d) for (NSDictionary *r in [NSJSONSerialization JSONObjectWithData:d options:0 error:nil]) { + BBBookmark *b=[BBBookmark new]; b.title=r[@"title"]?:@""; b.urlString=r[@"url"]?:@""; + b.addedAt=[NSDate dateWithTimeIntervalSince1970:[r[@"t"] doubleValue]]; + [_items addObject:b]; } - [handle seekToEndOfFile]; - [handle writeData:[withNewline dataUsingEncoding:NSUTF8StringEncoding]]; - [handle closeFile]; + return self; } +- (void)addTitle:(NSString *)t url:(NSString *)u { + BBBookmark *b=[BBBookmark new]; b.title=t?:@""; b.urlString=u?:@""; b.addedAt=[NSDate date]; + [self.items addObject:b]; [self save]; +} +- (void)removeAtIndex:(NSInteger)i { if(i>=0&&i<(NSInteger)self.items.count){[self.items removeObjectAtIndex:i];[self save];} } +- (BOOL)isBookmarked:(NSString *)u { for(BBBookmark *b in self.items) if([b.urlString isEqualToString:u]) return YES; return NO; } +- (void)save { + NSMutableArray *arr=[NSMutableArray array]; + for (BBBookmark *b in self.items) [arr addObject:@{@"title":b.title,@"url":b.urlString,@"t":@(b.addedAt.timeIntervalSince1970)}]; + NSData *d=[NSJSONSerialization dataWithJSONObject:arr options:0 error:nil]; + [[NSFileManager defaultManager] createDirectoryAtPath:BBSupportDir() withIntermediateDirectories:YES attributes:nil error:nil]; + [d writeToFile:[BBSupportDir() stringByAppendingPathComponent:@"bookmarks.json"] atomically:YES]; +} +@end + +// ── BBHistoryStore ──────────────────────────────────────────────────────────── +@interface BBHistoryEntry : NSObject +@property(copy) NSString *title, *urlString; +@property(strong) NSDate *visitedAt; +@end +@implementation BBHistoryEntry +@end -static NSString *BBJSON(NSDictionary *dict) { - NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:nil]; - if (!data) { return @"{}"; } - return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; +@interface BBHistoryStore : NSObject +@property(strong) NSMutableArray *entries; // newest-last, capped 20k ++ (instancetype)shared; +- (void)recordTitle:(NSString *)t url:(NSString *)u; +- (NSArray *)search:(NSString *)q limit:(NSInteger)n; +@end +@implementation BBHistoryStore ++ (instancetype)shared { static BBHistoryStore *s; static dispatch_once_t o; dispatch_once(&o,^{s=[[self alloc]init];}); return s; } +- (instancetype)init { + self=[super init]; _entries=[NSMutableArray array]; + NSString *path=[[BBSupportDir() stringByAppendingPathComponent:@"history"] stringByAppendingPathComponent:@"history.jsonl"]; + NSString *raw=[NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil]; + NSArray *lines=[raw componentsSeparatedByString:@"\n"]; + NSInteger start=MAX(0,(NSInteger)lines.count-20000); + for (NSInteger i=start;i<(NSInteger)lines.count;i++) { + NSData *d=[lines[i] dataUsingEncoding:NSUTF8StringEncoding]; if(!d.length) continue; + NSDictionary *obj=[NSJSONSerialization JSONObjectWithData:d options:0 error:nil]; if(!obj) continue; + BBHistoryEntry *e=[BBHistoryEntry new]; e.urlString=obj[@"url"]?:@""; e.title=obj[@"title"]?:@""; + e.visitedAt=[NSDate dateWithTimeIntervalSince1970:[obj[@"t"] doubleValue]]; + [_entries addObject:e]; + } + return self; } +- (void)recordTitle:(NSString *)t url:(NSString *)u { + if (!u.length||[u hasPrefix:@"bearbrowser://"]) return; + BBHistoryEntry *e=[BBHistoryEntry new]; e.title=t?:@""; e.urlString=u; e.visitedAt=[NSDate date]; + [self.entries addObject:e]; if(self.entries.count>20000) [self.entries removeObjectAtIndex:0]; + NSString *dir=[BBSupportDir() stringByAppendingPathComponent:@"history"]; + BBAppendLine([dir stringByAppendingPathComponent:@"history.jsonl"], + BBJSON(@{@"url":u,@"title":t?:@"",@"t":@(e.visitedAt.timeIntervalSince1970)})); +} +- (NSArray *)search:(NSString *)q limit:(NSInteger)n { + if(!q.length) return @[]; + NSString *ql=[q lowercaseString]; NSMutableArray *r=[NSMutableArray array]; NSMutableSet *seen=[NSMutableSet set]; + for (NSInteger i=self.entries.count-1;i>=0&&(NSInteger)r.count *items; +@property(strong) NSScrollView *scroll; +@property(strong) NSStackView *stack; +@property(strong) NSTimer *pollTimer; +- (void)addItem:(BBDownloadItem *)item; +- (void)refresh; +- (void)show; +- (void)hide; +@end + +@implementation BBDownloadPanel +- (instancetype)initWithFrame:(NSRect)f { + self=[super initWithFrame:f]; + self.wantsLayer=YES; self.layer.backgroundColor=[NSColor windowBackgroundColor].CGColor; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(themeChanged:) + name:NSSystemColorsDidChangeNotification object:nil]; + _items=[NSMutableArray array]; + // Header + NSTextField *hdr=[[NSTextField alloc]initWithFrame:NSMakeRect(12,f.size.height-36,f.size.width-24,24)]; + hdr.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + hdr.stringValue=@"Downloads"; hdr.font=[NSFont systemFontOfSize:13 weight:NSFontWeightSemibold]; + hdr.bordered=NO; hdr.editable=NO; hdr.selectable=NO; hdr.backgroundColor=[NSColor clearColor]; + [self addSubview:hdr]; + // Separator + NSBox *sep=[[NSBox alloc]initWithFrame:NSMakeRect(0,f.size.height-38,f.size.width,1)]; + sep.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; sep.boxType=NSBoxSeparator; [self addSubview:sep]; + // Scrollable stack + _stack=[NSStackView new]; _stack.orientation=NSUserInterfaceLayoutOrientationVertical; + _stack.alignment=NSLayoutAttributeLeading; _stack.spacing=1; + _stack.translatesAutoresizingMaskIntoConstraints=NO; + _scroll=[[NSScrollView alloc]initWithFrame:NSMakeRect(0,0,f.size.width,f.size.height-40)]; + _scroll.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + _scroll.hasVerticalScroller=YES; _scroll.drawsBackground=NO; + _scroll.documentView=_stack; [self addSubview:_scroll]; + [NSLayoutConstraint activateConstraints:@[ + [_stack.leadingAnchor constraintEqualToAnchor:_scroll.contentView.leadingAnchor], + [_stack.trailingAnchor constraintEqualToAnchor:_scroll.contentView.trailingAnchor], + [_stack.topAnchor constraintEqualToAnchor:_scroll.contentView.topAnchor], + ]]; + return self; +} +- (void)themeChanged:(NSNotification *)n { self.layer.backgroundColor=[NSColor windowBackgroundColor].CGColor; } +- (void)addItem:(BBDownloadItem *)item { + [self.items addObject:item]; + if (!self.pollTimer) self.pollTimer=[NSTimer scheduledTimerWithTimeInterval:0.5 target:self selector:@selector(pollFileSizes) userInfo:nil repeats:YES]; + [self refresh]; [self show]; +} +- (void)pollFileSizes { + BOOL anyActive=NO; + for (BBDownloadItem *item in self.items) { + if (item.state==BBDownloadStateActive && item.destURL) { + NSDictionary *attr=[[NSFileManager defaultManager] attributesOfItemAtPath:item.destURL.path error:nil]; + if (attr) item.writtenBytes=[attr[NSFileSize] longLongValue]; + anyActive=YES; + } + } + if (!anyActive) { [self.pollTimer invalidate]; self.pollTimer=nil; } + dispatch_async(dispatch_get_main_queue(),^{ [self refresh]; }); +} +- (void)refresh { + for (NSView *v in self.stack.arrangedSubviews) [self.stack removeArrangedSubview:v]; + for (NSView *v in self.stack.arrangedSubviews.copy) [v removeFromSuperview]; + for (BBDownloadItem *item in self.items.reverseObjectEnumerator.allObjects) { + NSView *row=[self rowForItem:item]; [self.stack addArrangedSubview:row]; + [NSLayoutConstraint activateConstraints:@[ + [row.leadingAnchor constraintEqualToAnchor:self.stack.leadingAnchor], + [row.trailingAnchor constraintEqualToAnchor:self.stack.trailingAnchor], + [row.heightAnchor constraintEqualToConstant:68], + ]]; + } +} +- (NSView *)rowForItem:(BBDownloadItem *)item { + NSView *row=[[NSView alloc]initWithFrame:NSZeroRect]; row.wantsLayer=YES; + row.layer.backgroundColor=[NSColor controlBackgroundColor].CGColor; + // Filename + NSTextField *name=[[NSTextField alloc]initWithFrame:NSZeroRect]; + name.translatesAutoresizingMaskIntoConstraints=NO; + name.stringValue=item.filename?:@"file"; name.font=[NSFont systemFontOfSize:12 weight:NSFontWeightMedium]; + name.bordered=NO; name.editable=NO; name.selectable=NO; name.backgroundColor=[NSColor clearColor]; + name.lineBreakMode=NSLineBreakByTruncatingMiddle; [row addSubview:name]; + // Status label + NSTextField *status=[[NSTextField alloc]initWithFrame:NSZeroRect]; + status.translatesAutoresizingMaskIntoConstraints=NO; + status.stringValue=[self statusStringForItem:item]; + status.font=[NSFont systemFontOfSize:11]; status.textColor=[NSColor secondaryLabelColor]; + status.bordered=NO; status.editable=NO; status.selectable=NO; status.backgroundColor=[NSColor clearColor]; + [row addSubview:status]; + // Progress bar + NSProgressIndicator *bar=[[NSProgressIndicator alloc]initWithFrame:NSZeroRect]; + bar.translatesAutoresizingMaskIntoConstraints=NO; + bar.style=NSProgressIndicatorStyleBar; bar.minValue=0; bar.maxValue=1; + bar.controlSize=NSControlSizeSmall; + double pct=(item.totalBytes>0)?(double)item.writtenBytes/item.totalBytes:(item.state==BBDownloadStateDone?1.0:0.0); + bar.indeterminate=(item.state==BBDownloadStateActive&&item.totalBytes<=0); + bar.doubleValue=pct; if(bar.indeterminate)[bar startAnimation:nil]; + bar.hidden=(item.state==BBDownloadStateFailed); + [row addSubview:bar]; + // Action button + NSButton *btn=[[NSButton alloc]initWithFrame:NSZeroRect]; + btn.translatesAutoresizingMaskIntoConstraints=NO; + btn.bezelStyle=NSBezelStyleToolbar; btn.bordered=NO; + NSString *sym=(item.state==BBDownloadStateDone)?@"arrow.down.circle.fill": + (item.state==BBDownloadStateFailed)?@"arrow.clockwise":@"xmark.circle"; + NSImage *img=[NSImage imageWithSystemSymbolName:sym accessibilityDescription:@"Action"]; + img=[img imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:14 weight:NSFontWeightMedium]]; + [img setTemplate:YES]; btn.image=img; + btn.target=self; btn.action=@selector(downloadAction:); + // tag = display row index (0 = newest shown at top) + NSInteger displayIdx=[self.items.reverseObjectEnumerator.allObjects indexOfObject:item]; + btn.tag=(displayIdx==NSNotFound)?0:(NSInteger)displayIdx; + [row addSubview:btn]; + // Layout + [NSLayoutConstraint activateConstraints:@[ + [name.leadingAnchor constraintEqualToAnchor:row.leadingAnchor constant:12], + [name.trailingAnchor constraintEqualToAnchor:btn.leadingAnchor constant:-4], + [name.topAnchor constraintEqualToAnchor:row.topAnchor constant:10], + [status.leadingAnchor constraintEqualToAnchor:name.leadingAnchor], + [status.trailingAnchor constraintEqualToAnchor:name.trailingAnchor], + [status.topAnchor constraintEqualToAnchor:name.bottomAnchor constant:2], + [bar.leadingAnchor constraintEqualToAnchor:name.leadingAnchor], + [bar.trailingAnchor constraintEqualToAnchor:name.trailingAnchor], + [bar.topAnchor constraintEqualToAnchor:status.bottomAnchor constant:5], + [btn.trailingAnchor constraintEqualToAnchor:row.trailingAnchor constant:-10], + [btn.centerYAnchor constraintEqualToAnchor:row.centerYAnchor], + [btn.widthAnchor constraintEqualToConstant:28], + [btn.heightAnchor constraintEqualToConstant:28], + ]]; + return row; +} +- (NSString *)statusStringForItem:(BBDownloadItem *)item { + if (item.state==BBDownloadStateFailed) return item.errorMessage?:@"Failed"; + if (item.state==BBDownloadStateDone) return [NSString stringWithFormat:@"Done — %@",[self sizeStr:item.writtenBytes]]; + if (item.totalBytes>0) return [NSString stringWithFormat:@"%@ of %@",[self sizeStr:item.writtenBytes],[self sizeStr:item.totalBytes]]; + return item.writtenBytes>0?[self sizeStr:item.writtenBytes]:@"Waiting…"; +} +- (NSString *)sizeStr:(long long)b { + if(b<1024) return [NSString stringWithFormat:@"%lld B",b]; + if(b<1024*1024) return [NSString stringWithFormat:@"%.1f KB",(double)b/1024]; + if(b<1024*1024*1024) return [NSString stringWithFormat:@"%.1f MB",(double)b/(1024*1024)]; + return [NSString stringWithFormat:@"%.2f GB",(double)b/(1024*1024*1024)]; +} +- (void)downloadAction:(NSButton *)btn { + NSInteger idx=self.items.count-1-btn.tag; // rows displayed newest-first + if(idx<0||idx>=(NSInteger)self.items.count) return; + BBDownloadItem *item=self.items[idx]; + if (item.state==BBDownloadStateDone && item.destURL) + [[NSWorkspace sharedWorkspace] activateFileViewerSelectingURLs:@[item.destURL]]; + else if (item.state==BBDownloadStateFailed) + { item.state=BBDownloadStateActive; [self refresh]; } + else if (item.download) + [item.download cancel:^(NSData *rd){}]; +} +- (void)show { self.hidden=NO; } +- (void)hide { self.hidden=YES; } +@end + +// ── BBAddressDropdown ───────────────────────────────────────────────────────── +@interface BBAddressSuggestion : NSObject +@property(copy) NSString *title, *urlString, *badge; // badge: "Bookmark", "History", "Search" +@end +@implementation BBAddressSuggestion +@end + +@protocol BBAddressDropdownDelegate +- (void)dropdownSelectedURL:(NSString *)urlString; +@end + +// NSView-based overlay — no child window, no focus theft. +@interface BBAddressDropdown : NSObject +@property(strong) NSView *overlay; // lives in main window's contentView +@property(strong) NSTableView *table; +@property(strong) NSMutableArray *suggestions; +@property(weak) id delegate; +@property(strong) NSTimer *ddgTimer; +- (void)updateForQuery:(NSString *)q belowField:(NSTextField *)field inWindow:(NSWindow *)win; +- (void)hide; +- (BOOL)selectNext; +- (BOOL)selectPrev; +- (BOOL)confirmSelection; +@end + +@implementation BBAddressDropdown +- (instancetype)init { + self=[super init]; _suggestions=[NSMutableArray array]; + // Overlay container — added to contentView on first show + _overlay=[[NSView alloc]initWithFrame:NSZeroRect]; + _overlay.wantsLayer=YES; + _overlay.hidden=YES; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(themeChanged:) + name:NSSystemColorsDidChangeNotification object:nil]; + NSScrollView *scroll=[[NSScrollView alloc]initWithFrame:_overlay.bounds]; + scroll.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + scroll.hasVerticalScroller=NO; scroll.drawsBackground=NO; + _table=[[NSTableView alloc]init]; _table.headerView=nil; + _table.rowHeight=40; _table.intercellSpacing=NSMakeSize(0,0); + _table.backgroundColor=[NSColor clearColor]; + NSTableColumn *col=[[NSTableColumn alloc]initWithIdentifier:@"row"]; col.width=600; + [_table addTableColumn:col]; _table.dataSource=self; _table.delegate=self; + scroll.documentView=_table; [_overlay addSubview:scroll]; + return self; +} +- (void)themeChanged:(NSNotification *)n { [_overlay setNeedsDisplay:YES]; } +- (void)updateForQuery:(NSString *)q belowField:(NSTextField *)field inWindow:(NSWindow *)win { + [_suggestions removeAllObjects]; + if (!q.length) { [self hide]; return; } + // Bookmarks first + for (BBBookmark *b in [BBBookmarksStore shared].items) { + if ([[b.urlString lowercaseString] containsString:q.lowercaseString]|| + [[b.title lowercaseString] containsString:q.lowercaseString]) { + BBAddressSuggestion *s=[BBAddressSuggestion new]; s.title=b.title; s.urlString=b.urlString; s.badge=@"★"; + [_suggestions addObject:s]; if(_suggestions.count>=3) break; + } + } + // History + for (BBHistoryEntry *e in [[BBHistoryStore shared] search:q limit:6]) { + BBAddressSuggestion *s=[BBAddressSuggestion new]; s.title=e.title.length?e.title:e.urlString; + s.urlString=e.urlString; s.badge=@"↺"; [_suggestions addObject:s]; + if(_suggestions.count>=9) break; + } + // Search row always last + BBAddressSuggestion *search=[BBAddressSuggestion new]; + search.title=[NSString stringWithFormat:@"Search DuckDuckGo: %@",q]; + NSString *eq=[q stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; + search.urlString=[NSString stringWithFormat:@"https://duckduckgo.com/?q=%@",eq]; + search.badge=@"⌕"; [_suggestions addObject:search]; + [_table reloadData]; [_table deselectAll:nil]; + // Position overlay in contentView coordinates below the address field + NSView *cv=win.contentView; + if (_overlay.superview!=cv) [cv addSubview:_overlay positioned:NSWindowAbove relativeTo:nil]; + NSRect fieldInContent=[field.superview convertRect:field.frame toView:cv]; + CGFloat rowH=40; CGFloat h=MIN((CGFloat)_suggestions.count*rowH,280); + _overlay.frame=NSMakeRect(fieldInContent.origin.x, + fieldInContent.origin.y-h, + fieldInContent.size.width, h); + _overlay.hidden=NO; + // DDG autocomplete after 250ms debounce + [_ddgTimer invalidate]; NSString *qc=q; + _ddgTimer=[NSTimer scheduledTimerWithTimeInterval:0.25 repeats:NO block:^(NSTimer *t){ + [self fetchDDGSuggestions:qc]; + }]; +} +- (void)fetchDDGSuggestions:(NSString *)q { + NSString *eq=[q stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; + NSURL *url=[NSURL URLWithString:[NSString stringWithFormat:@"https://duckduckgo.com/ac/?q=%@&type=list",eq]]; + [[[NSURLSession sharedSession] dataTaskWithURL:url completionHandler:^(NSData *d,NSURLResponse *r,NSError *e){ + if(e||!d) return; + NSArray *resp=[NSJSONSerialization JSONObjectWithData:d options:0 error:nil]; + NSArray *terms=(resp.count>1&&[resp[1] isKindOfClass:[NSArray class]])?resp[1]:@[]; + dispatch_async(dispatch_get_main_queue(),^{ + // Replace old DDG completions (not the search row) with fresh ones + [self.suggestions removeObjectsAtIndexes:[self.suggestions indexesOfObjectsPassingTest: + ^BOOL(BBAddressSuggestion *s,NSUInteger i,BOOL *stop){ + return [s.badge isEqualToString:@"⌕"] && ![s.title hasPrefix:@"Search DuckDuckGo"];}]]; + NSInteger ins=MAX(0,(NSInteger)self.suggestions.count-1); + for (NSString *term in terms) { + if(![term isKindOfClass:[NSString class]]||!term.length) continue; + BBAddressSuggestion *s=[BBAddressSuggestion new]; s.title=term; s.badge=@"⌕"; + NSString *teq=[term stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]; + s.urlString=[NSString stringWithFormat:@"https://duckduckgo.com/?q=%@",teq]; + if(ins<(NSInteger)self.suggestions.count) [self.suggestions insertObject:s atIndex:ins++]; + if(self.suggestions.count>=12) break; + } + if(terms.count) [self.table reloadData]; + }); + }] resume]; +} +- (void)hide { + [_ddgTimer invalidate]; _ddgTimer=nil; + _overlay.hidden=YES; [_table deselectAll:nil]; +} +- (BOOL)selectNext { + if(!_suggestions.count||_overlay.hidden) return NO; + NSInteger row=MIN(_table.selectedRow+1,(NSInteger)_suggestions.count-1); + [_table selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; + [_table scrollRowToVisible:row]; return YES; +} +- (BOOL)selectPrev { + if(!_suggestions.count||_overlay.hidden) return NO; + NSInteger row=_table.selectedRow; + if(row<=0){[_table deselectAll:nil];return YES;} + row=MAX(row-1,0); + [_table selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; + [_table scrollRowToVisible:row]; return YES; +} +- (BOOL)confirmSelection { + NSInteger row=_table.selectedRow; + if(row<0||row>=(NSInteger)_suggestions.count||_overlay.hidden) return NO; + NSString *url=_suggestions[row].urlString; + [self hide]; [self.delegate dropdownSelectedURL:url]; return YES; +} +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tv { return _suggestions.count; } +- (NSView *)tableView:(NSTableView *)tv viewForTableColumn:(NSTableColumn *)col row:(NSInteger)row { + NSTableCellView *cell=[tv makeViewWithIdentifier:@"dd" owner:self]; + if (!cell) { + cell=[[NSTableCellView alloc]initWithFrame:NSMakeRect(0,0,560,40)]; cell.identifier=@"dd"; + NSTextField *badge=[[NSTextField alloc]initWithFrame:NSMakeRect(8,10,20,20)]; + badge.tag=1; badge.bordered=NO; badge.editable=NO; badge.selectable=NO; + badge.backgroundColor=[NSColor clearColor]; badge.font=[NSFont systemFontOfSize:11]; + badge.textColor=[NSColor tertiaryLabelColor]; badge.alignment=NSTextAlignmentCenter; + [cell addSubview:badge]; + NSTextField *title=[[NSTextField alloc]initWithFrame:NSMakeRect(32,22,496,16)]; + title.tag=2; title.bordered=NO; title.editable=NO; title.selectable=NO; + title.backgroundColor=[NSColor clearColor]; title.font=[NSFont systemFontOfSize:12 weight:NSFontWeightMedium]; + title.lineBreakMode=NSLineBreakByTruncatingTail; [cell addSubview:title]; + NSTextField *urlLbl=[[NSTextField alloc]initWithFrame:NSMakeRect(32,4,496,16)]; + urlLbl.tag=3; urlLbl.bordered=NO; urlLbl.editable=NO; urlLbl.selectable=NO; + urlLbl.backgroundColor=[NSColor clearColor]; urlLbl.font=[NSFont systemFontOfSize:10]; + urlLbl.textColor=[NSColor secondaryLabelColor]; urlLbl.lineBreakMode=NSLineBreakByTruncatingTail; + [cell addSubview:urlLbl]; + } + BBAddressSuggestion *s=_suggestions[row]; + ((NSTextField *)[cell viewWithTag:1]).stringValue=s.badge?:@""; + ((NSTextField *)[cell viewWithTag:2]).stringValue=s.title?:@""; + ((NSTextField *)[cell viewWithTag:3]).stringValue=s.urlString?:@""; + return cell; +} +- (CGFloat)tableView:(NSTableView *)tv heightOfRow:(NSInteger)row { return 40; } +- (BOOL)tableView:(NSTableView *)tv shouldSelectRow:(NSInteger)row { return YES; } +- (void)tableViewSelectionDidChange:(NSNotification *)n { + // Mouse click in the table — navigate immediately + NSInteger row=_table.selectedRow; + if(row>=0&&row<(NSInteger)_suggestions.count&&!_overlay.hidden) + [self.delegate dropdownSelectedURL:_suggestions[row].urlString]; +} +@end + +// ── BBHistoryPanelDS ────────────────────────────────────────────────────────── +// A lightweight datasource/delegate for the history NSTableView. +@interface BBHistoryPanelDS : NSObject +@property(strong) NSMutableArray *all, *shown; +@property(strong) NSTableView *tv; +@property(weak) NSWindow *win; +@property(weak) WKWebView *webView; +- (instancetype)initWithEntries:(NSMutableArray *)e tableView:(NSTableView *)tv searchField:(NSSearchField *)sf window:(NSWindow *)w webView:(WKWebView *)wv; +@end +@implementation BBHistoryPanelDS +- (instancetype)initWithEntries:(NSMutableArray *)e tableView:(NSTableView *)tv searchField:(NSSearchField *)sf window:(NSWindow *)w webView:(WKWebView *)wv { + self=[super init]; _all=e; _shown=[e mutableCopy]; _tv=tv; _win=w; _webView=wv; return self; +} +- (NSInteger)numberOfRowsInTableView:(NSTableView *)tv { return _shown.count; } +- (NSView *)tableView:(NSTableView *)tv viewForTableColumn:(NSTableColumn *)col row:(NSInteger)row { + NSTextField *f=[tv makeViewWithIdentifier:col.identifier owner:self]; + if (!f) { f=[[NSTextField alloc]init]; f.identifier=col.identifier; f.bordered=NO; f.editable=NO; f.selectable=NO; f.backgroundColor=[NSColor clearColor]; f.lineBreakMode=NSLineBreakByTruncatingTail; } + BBHistoryEntry *e=_shown[row]; + NSDateFormatter *df=[NSDateFormatter new]; df.timeStyle=NSDateFormatterShortStyle; df.dateStyle=NSDateFormatterShortStyle; + if ([col.identifier isEqualToString:@"title"]) f.stringValue=e.title.length?e.title:e.urlString; + else if ([col.identifier isEqualToString:@"url"]) f.stringValue=e.urlString; + else f.stringValue=[df stringFromDate:e.visitedAt]?:@""; + return f; +} +- (void)tableViewSelectionDidChange:(NSNotification *)n { + NSInteger row=_tv.selectedRow; + if(row<0||row>=(NSInteger)_shown.count) return; + NSURL *u=[NSURL URLWithString:_shown[row].urlString]; if(!u) return; + [_webView loadRequest:[NSURLRequest requestWithURL:u]]; + [_win.sheetParent endSheet:_win]; [_win orderOut:nil]; +} +- (void)controlTextDidChange:(NSNotification *)n { + NSString *q=((NSSearchField *)n.object).stringValue; + if(!q.length) { _shown=[_all mutableCopy]; [_tv reloadData]; return; } + NSString *ql=q.lowercaseString; + _shown=[[_all objectsAtIndexes:[_all indexesOfObjectsPassingTest:^BOOL(BBHistoryEntry *e,NSUInteger i,BOOL *s){ + return [[e.urlString lowercaseString] containsString:ql]||[[e.title lowercaseString] containsString:ql]; + }]] mutableCopy]; + [_tv reloadData]; +} +@end + +// ── BBConnectionRecord ──────────────────────────────────────────────────────── +typedef NS_ENUM(NSInteger,BBConnCategory){ + BBConnCategoryFirstParty=0,BBConnCategoryTracker,BBConnCategoryAnalytics, + BBConnCategoryCDN,BBConnCategoryUnknown +}; +@interface BBConnectionRecord : NSObject +@property(copy) NSString *domain; +@property(copy) NSString *pageURL; +@property(copy) NSString *resourceType; +@property(strong) NSDate *timestamp; +@property(assign) BOOL blocked; +@property(assign) BBConnCategory category; ++(NSString*)etldForHost:(NSString*)host; ++(BBConnCategory)classify:(NSString*)etld; +@end +@implementation BBConnectionRecord ++(NSString*)etldForHost:(NSString*)host { + if(!host.length) return @""; + NSArray *p=[host componentsSeparatedByString:@"."]; + if(p.count<2) return host; + return [NSString stringWithFormat:@"%@.%@",p[p.count-2],p[p.count-1]]; +} ++(BBConnCategory)classify:(NSString*)etld { + static NSSet *trackers=nil,*analytics=nil,*cdns=nil; + static dispatch_once_t once; + dispatch_once(&once,^{ + trackers=[NSSet setWithArray:@[@"doubleclick.net",@"googlesyndication.com",@"connect.facebook.net", + @"criteo.com",@"adnxs.com",@"rubiconproject.com",@"pubmatic.com",@"openx.net", + @"taboola.com",@"outbrain.com",@"moatads.com",@"scorecardresearch.com", + @"quantserve.com",@"turn.com",@"bidswitch.net",@"casalemedia.com"]]; + analytics=[NSSet setWithArray:@[@"google-analytics.com",@"googletagmanager.com",@"mixpanel.com", + @"amplitude.com",@"segment.io",@"segment.com",@"heap.io",@"hotjar.com", + @"fullstory.com",@"logrocket.com",@"smartlook.com"]]; + cdns=[NSSet setWithArray:@[@"cloudflare.com",@"fastly.net",@"cloudfront.net", + @"akamaized.net",@"jsdelivr.net",@"unpkg.com",@"amazonaws.com", + @"googleapis.com",@"gstatic.com",@"bootstrapcdn.com",@"jquery.com", + @"cdnjs.cloudflare.com",@"azureedge.net",@"stackpath.bootstrapcdn.com"]]; + }); + if([trackers containsObject:etld]) return BBConnCategoryTracker; + if([analytics containsObject:etld]) return BBConnCategoryAnalytics; + if([cdns containsObject:etld]) return BBConnCategoryCDN; + return BBConnCategoryUnknown; +} +@end + +// ── BBNetworkMonitor ────────────────────────────────────────────────────────── +@interface BBNetworkMonitor : NSObject ++(instancetype)shared; +-(void)record:(NSString*)domain page:(NSString*)page type:(NSString*)type blocked:(BOOL)blocked; +-(NSArray*)snapshot; +-(void)clear; +@property(copy) void(^onNewRecord)(BBConnectionRecord*); +@end +@implementation BBNetworkMonitor { + NSMutableArray *_recs; + dispatch_queue_t _q; +} ++(instancetype)shared{static BBNetworkMonitor*s;static dispatch_once_t o;dispatch_once(&o,^{s=[[self alloc]init];});return s;} +-(instancetype)init{ + self=[super init]; + _recs=[NSMutableArray arrayWithCapacity:2000]; + _q=dispatch_queue_create("io.bearbrowser.netmon",DISPATCH_QUEUE_SERIAL); + return self; +} +-(void)record:(NSString*)domain page:(NSString*)page type:(NSString*)type blocked:(BOOL)blocked { + if(!domain.length) return; + NSString *etld=[BBConnectionRecord etldForHost:domain]; + BBConnectionRecord *r=[BBConnectionRecord new]; + r.domain=etld; r.pageURL=page?:@""; r.resourceType=type?:@""; + r.timestamp=[NSDate date]; r.blocked=blocked; + r.category=[BBConnectionRecord classify:etld]; + dispatch_async(_q,^{ + if(_recs.count>=5000)[_recs removeObjectsInRange:NSMakeRange(0,500)]; + [_recs addObject:r]; + }); + if(self.onNewRecord) dispatch_async(dispatch_get_main_queue(),^{self.onNewRecord(r);}); +} +-(NSArray*)snapshot{__block NSArray*s;dispatch_sync(_q,^{s=[_recs copy];});return s;} +-(void)clear{dispatch_async(_q,^{[_recs removeAllObjects];});} +@end + +// ── BBSecurityMonitor ───────────────────────────────────────────────────────── +typedef NS_ENUM(NSInteger,BBSecSeverity){BBSecLow=0,BBSecMedium,BBSecHigh,BBSecCritical}; + +@interface BBSecurityEvent : NSObject +@property(copy) NSString *type; // eval, script_inject, beacon, form_submit, … +@property(copy) NSString *pageURL; +@property(copy) NSString *detail; // truncated snippet / url / field list +@property(strong) NSDate *timestamp; +@property(assign) BBSecSeverity severity; +@end +@implementation BBSecurityEvent @end + +@interface BBSecurityMonitor : NSObject ++(instancetype)shared; +-(void)record:(NSString*)type page:(NSString*)page detail:(NSString*)detail severity:(BBSecSeverity)sev; +-(NSArray*)snapshot; +@property(copy) void(^onNewEvent)(BBSecurityEvent*); +@end + +@implementation BBSecurityMonitor { + NSMutableArray *_evts; + dispatch_queue_t _q; +} ++(instancetype)shared{static BBSecurityMonitor*s;static dispatch_once_t o;dispatch_once(&o,^{s=[[self alloc]init];});return s;} +-(instancetype)init{self=[super init];_evts=[NSMutableArray array];_q=dispatch_queue_create("io.bearbrowser.secmon",DISPATCH_QUEUE_SERIAL);return self;} +-(void)record:(NSString*)type page:(NSString*)page detail:(NSString*)detail severity:(BBSecSeverity)sev { + BBSecurityEvent *e=[BBSecurityEvent new]; + e.type=type; e.pageURL=page; e.detail=detail; e.severity=sev; e.timestamp=[NSDate date]; + dispatch_async(_q,^{ + [_evts addObject:e]; + if(_evts.count>2000)[_evts removeObjectAtIndex:0]; + if(self.onNewEvent) dispatch_async(dispatch_get_main_queue(),^{self.onNewEvent(e);}); + }); +} +-(NSArray*)snapshot{__block NSArray*s;dispatch_sync(_q,^{s=[_evts copy];});return s;} +@end + +// Heuristic severity classifier — runs on JS-side metadata, not full AST parse +static BBSecSeverity BBSecClassify(NSString *type, NSString *detail) { + if ([type isEqualToString:@"credentials_get"] || + [type isEqualToString:@"credentials_create"]) return BBSecCritical; + if ([type isEqualToString:@"eval"] || [type isEqualToString:@"Function"]) { + // Obfuscation / exfil patterns in eval'd body → critical + NSArray *hotPats = @[@"atob(", @"btoa(", @"document.cookie", @"localStorage", + @"sendBeacon", @"keydown", @"keypress", @"fromCharCode"]; + for (NSString *p in hotPats) + if ([detail containsString:p]) return BBSecCritical; + return BBSecHigh; + } + if ([type isEqualToString:@"beacon"]) return BBSecHigh; + if ([type isEqualToString:@"keylistener"]) return BBSecCritical; + if ([type isEqualToString:@"script_inject"]) { + // Inline script with obfuscation patterns + NSArray *obfPats = @[@"eval(", @"atob(", @"String.fromCharCode", @"\\x", @"unescape("]; + for (NSString *p in obfPats) + if ([detail containsString:p]) return BBSecHigh; + return BBSecMedium; + } + if ([type isEqualToString:@"form_submit"]) return BBSecMedium; + if ([type isEqualToString:@"document_write"]) return BBSecMedium; + if ([type isEqualToString:@"localstorage_auth"])return BBSecMedium; + if ([type isEqualToString:@"cookie_auth"]) return BBSecMedium; + return BBSecLow; +} + +// ── BBFirewall ──────────────────────────────────────────────────────────────── +typedef NS_ENUM(NSInteger,BBFirewallDecision){BBFWAsk=0,BBFWAllow,BBFWBlock}; +@interface BBFirewall : NSObject ++(instancetype)shared; +-(BBFirewallDecision)decisionFor:(NSString*)domain; +-(void)set:(BBFirewallDecision)d for:(NSString*)domain; +-(NSDictionary*)allRules; +@end +@implementation BBFirewall{NSMutableDictionary*_rules;} ++(instancetype)shared{static BBFirewall*s;static dispatch_once_t o;dispatch_once(&o,^{s=[[self alloc]init];});return s;} +-(instancetype)init{ + self=[super init]; + NSDictionary *saved=[[NSUserDefaults standardUserDefaults]dictionaryForKey:@"BBFirewallRules"]; + _rules=[NSMutableDictionary dictionaryWithDictionary:saved?:@{}]; + return self; +} +-(BBFirewallDecision)decisionFor:(NSString*)d{NSNumber*n=_rules[d];return n?n.integerValue:BBFWAsk;} +-(void)set:(BBFirewallDecision)d for:(NSString*)domain{ + if(d==BBFWAsk)[_rules removeObjectForKey:domain]; else _rules[domain]=@(d); + [[NSUserDefaults standardUserDefaults]setObject:[_rules copy] forKey:@"BBFirewallRules"]; +} +-(NSDictionary*)allRules{return[_rules copy];} +@end + +// ── BBPacketCapture ─────────────────────────────────────────────────────────── +@interface BBPacketCapture : NSObject ++(BOOL)available; // tshark or tcpdump present? ++(NSString*)captureBinary; +-(void)startCapturingHost:(NSString*)host output:(void(^)(NSString*))lineHandler; +-(void)stop; +-(NSURL*)savePcapAndGetURL; // write captured data to ~/Desktop and return URL +@end +@implementation BBPacketCapture{ + NSTask *_task; + NSFileHandle *_fh; + NSMutableData *_pcapBuf; +} ++(BOOL)available{return [self captureBinary].length>0;} ++(NSString*)captureBinary{ + for(NSString*p in @[@"/opt/homebrew/bin/tshark",@"/usr/local/bin/tshark", + @"/opt/homebrew/bin/tcpdump",@"/usr/sbin/tcpdump"]) + if([[NSFileManager defaultManager]isExecutableFileAtPath:p]) return p; + return @""; +} +-(void)startCapturingHost:(NSString*)host output:(void(^)(NSString*))handler{ + [self stop]; + _pcapBuf=[NSMutableData data]; + NSString *bin=[BBPacketCapture captureBinary]; + if(!bin.length) return; + _task=[[NSTask alloc]init]; + _task.launchPath=bin; + BOOL isTshark=[bin containsString:@"tshark"]; + if(isTshark){ + _task.arguments=host.length + ? @[@"-i",@"any",@"-Y",[NSString stringWithFormat:@"ip.host contains \"%@\"",host], + @"-T",@"fields",@"-e",@"frame.time_relative",@"-e",@"ip.src",@"-e",@"ip.dst", + @"-e",@"tcp.dstport",@"-e",@"frame.len",@"-E",@"separator= | "] + : @[@"-i",@"any",@"-T",@"fields",@"-e",@"frame.time_relative",@"-e",@"ip.src", + @"-e",@"ip.dst",@"-e",@"tcp.dstport",@"-e",@"frame.len",@"-E",@"separator= | "]; + } else { + _task.arguments=host.length + ? @[@"-l",@"-n",@"-i",@"any",@"host",host] + : @[@"-l",@"-n",@"-i",@"any",@"port",@"443",@"or",@"port",@"80"]; + } + NSPipe *pipe=[NSPipe pipe]; + _task.standardOutput=pipe; _task.standardError=pipe; + __weak BBPacketCapture *weakCapture=self; + [[NSNotificationCenter defaultCenter]addObserverForName:NSFileHandleDataAvailableNotification + object:pipe.fileHandleForReading queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification*n){ + BBPacketCapture *strong=weakCapture; if(!strong) return; + NSData *d=pipe.fileHandleForReading.availableData; + if(d.length){ + [strong->_pcapBuf appendData:d]; + NSString *s=[[NSString alloc]initWithData:d encoding:NSUTF8StringEncoding]?:@""; + for(NSString *line in[s componentsSeparatedByString:@"\n"]) + if(line.length) handler(line); + [pipe.fileHandleForReading waitForDataInBackgroundAndNotify]; + } + }]; + [pipe.fileHandleForReading waitForDataInBackgroundAndNotify]; + NSError *err=nil; + [_task launchAndReturnError:&err]; + if(err) handler([NSString stringWithFormat:@"Error: %@",err.localizedDescription]); +} +-(void)stop{if(_task.isRunning)[_task terminate]; _task=nil;} +-(NSURL*)savePcapAndGetURL{ + if(!_pcapBuf.length) return nil; + NSString *ts=[NSString stringWithFormat:@"bearbrowser-capture-%ld.txt",(long)[[NSDate date]timeIntervalSince1970]]; + NSURL *dest=[NSURL fileURLWithPath:[[@"~/Desktop" stringByExpandingTildeInPath] stringByAppendingPathComponent:ts]]; + [_pcapBuf writeToURL:dest atomically:YES]; + return dest; +} +@end + +// ── BBNetworkMapPanel ───────────────────────────────────────────────────────── +@interface BBNetworkMapPanel : NSObject ++(instancetype)shared; +-(void)showOrFocus; +-(void)pushRecord:(BBConnectionRecord*)r; +@end + +static NSString *kMapHTML(void) { + return + @"" + @"" + @"
" + @"
" + @"
First-party
" + @"
CDN
" + @"
Analytics
" + @"
Tracker
" + @"
Unknown
" + @"
Blocked
" + @"
" + @"
" + @""; +} + +@implementation BBNetworkMapPanel { + NSPanel *_panel; + NSTableView *_table; + WKWebView *_graphView; + NSTextField *_statsLabel; + NSButton *_monitorBtn; + BBPacketCapture *_capture; + NSPanel *_capturePanel; + NSTextView *_captureOutput; + // Domain-level aggregation for the table + NSMutableArray *_domains; // [{domain,count,blocked,cat}] + NSMutableDictionary *_domainMap; +} ++(instancetype)shared{static BBNetworkMapPanel*s;static dispatch_once_t o;dispatch_once(&o,^{s=[[self alloc]init];});return s;} +-(instancetype)init{ + self=[super init]; + _domains=[NSMutableArray array]; + _domainMap=[NSMutableDictionary dictionary]; + _capture=[[BBPacketCapture alloc]init]; + return self; +} +-(void)buildPanelIfNeeded { + if(_panel) return; + NSRect r=NSMakeRect(200,200,900,560); + _panel=[[NSPanel alloc]initWithContentRect:r + styleMask:(NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskResizable|NSWindowStyleMaskMiniaturizable) + backing:NSBackingStoreBuffered defer:NO]; + _panel.title=@"BearBrowser Network Monitor"; + _panel.minSize=NSMakeSize(600,400); + _panel.becomesKeyOnlyIfNeeded=YES; + + NSView *cv=_panel.contentView; cv.wantsLayer=YES; + cv.layer.backgroundColor=[NSColor colorWithWhite:0.11 alpha:1].CGColor; + CGFloat W=cv.bounds.size.width, H=cv.bounds.size.height; + + // ── Top toolbar ── + NSView *bar=[[NSView alloc]initWithFrame:NSMakeRect(0,H-44,W,44)]; + bar.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + bar.wantsLayer=YES; bar.layer.backgroundColor=[NSColor colorWithWhite:0.14 alpha:1].CGColor; + [cv addSubview:bar]; + + _statsLabel=[[NSTextField alloc]initWithFrame:NSMakeRect(12,12,260,20)]; + _statsLabel.editable=NO; _statsLabel.bordered=NO; _statsLabel.backgroundColor=[NSColor clearColor]; + _statsLabel.textColor=[NSColor colorWithWhite:0.6 alpha:1]; + _statsLabel.font=[NSFont monospacedSystemFontOfSize:11 weight:NSFontWeightRegular]; + _statsLabel.stringValue=@"No connections yet"; [bar addSubview:_statsLabel]; + + // Clear button + NSButton *clearBtn=[NSButton buttonWithTitle:@"Clear" target:self action:@selector(clearAll:)]; + clearBtn.frame=NSMakeRect(W-280,8,60,26); clearBtn.autoresizingMask=NSViewMinXMargin; + clearBtn.bezelStyle=NSBezelStyleRounded; + clearBtn.font=[NSFont systemFontOfSize:12]; [bar addSubview:clearBtn]; + + // Firewall button + NSButton *fwBtn=[NSButton buttonWithTitle:@"Firewall Rules" target:self action:@selector(openFirewall:)]; + fwBtn.frame=NSMakeRect(W-210,8,100,26); fwBtn.autoresizingMask=NSViewMinXMargin; + fwBtn.bezelStyle=NSBezelStyleRounded; + fwBtn.font=[NSFont systemFontOfSize:12]; [bar addSubview:fwBtn]; + + // Capture button + _monitorBtn=[NSButton buttonWithTitle:@"Start Capture" target:self action:@selector(toggleCapture:)]; + _monitorBtn.frame=NSMakeRect(W-100,8,90,26); _monitorBtn.autoresizingMask=NSViewMinXMargin; + _monitorBtn.bezelStyle=NSBezelStyleRounded; + _monitorBtn.font=[NSFont systemFontOfSize:12]; [bar addSubview:_monitorBtn]; + + // ── Split view ── + NSSplitView *split=[[NSSplitView alloc]initWithFrame:NSMakeRect(0,0,W,H-44)]; + split.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + split.vertical=YES; split.dividerStyle=NSSplitViewDividerStyleThin; + [cv addSubview:split]; + + // Left: domain table + NSScrollView *sv=[[NSScrollView alloc]initWithFrame:NSMakeRect(0,0,220,H-44)]; + sv.hasVerticalScroller=YES; sv.autohidesScrollers=YES; + sv.drawsBackground=NO; + _table=[[NSTableView alloc]initWithFrame:sv.contentView.bounds]; + _table.backgroundColor=[NSColor colorWithWhite:0.13 alpha:1]; + _table.gridColor=[NSColor colorWithWhite:0.18 alpha:1]; + _table.gridStyleMask=NSTableViewSolidHorizontalGridLineMask; + _table.rowHeight=26; _table.headerView=nil; + _table.dataSource=self; _table.delegate=self; + _table.allowsEmptySelection=YES; + NSTableColumn *domCol=[[NSTableColumn alloc]initWithIdentifier:@"domain"]; + domCol.width=120; [_table addTableColumn:domCol]; + NSTableColumn *cntCol=[[NSTableColumn alloc]initWithIdentifier:@"count"]; + cntCol.width=40; [_table addTableColumn:cntCol]; + NSTableColumn *stCol=[[NSTableColumn alloc]initWithIdentifier:@"status"]; + stCol.width=16; [_table addTableColumn:stCol]; + sv.documentView=_table; + [split addSubview:sv]; + + // Right: network graph + WKWebViewConfiguration *cfg=[[WKWebViewConfiguration alloc]init]; + [cfg.userContentController addScriptMessageHandler:self name:@"mapAction"]; + _graphView=[[WKWebView alloc]initWithFrame:NSMakeRect(0,0,W-220,H-44) configuration:cfg]; + _graphView.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + [_graphView loadHTMLString:kMapHTML() baseURL:nil]; + [split addSubview:_graphView]; + [split setPosition:220 ofDividerAtIndex:0]; +} +-(void)showOrFocus { + [self buildPanelIfNeeded]; + if(!_panel.visible)[_panel orderFront:nil]; + [_panel makeKeyAndOrderFront:nil]; + [self refreshTable]; +} +-(void)pushRecord:(BBConnectionRecord*)r { + dispatch_async(dispatch_get_main_queue(),^{ + NSMutableDictionary *entry=self->_domainMap[r.domain]; + if(!entry){ + entry=[NSMutableDictionary dictionaryWithDictionary:@{ + @"domain":r.domain, @"count":@1, @"blocked":@(r.blocked), + @"cat":[self catString:r.category], @"page":r.pageURL + }]; + self->_domainMap[r.domain]=entry; + [self->_domains addObject:entry]; + } else { + entry[@"count"]=@([entry[@"count"] integerValue]+1); + if(r.blocked) entry[@"blocked"]=@YES; + } + if(self->_panel.visible){ + [self refreshTable]; + [self pushGraphUpdate]; + } + }); +} +-(NSString*)catString:(BBConnCategory)c { + switch(c){ + case BBConnCategoryFirstParty: return @"first-party"; + case BBConnCategoryTracker: return @"tracker"; + case BBConnCategoryAnalytics: return @"analytics"; + case BBConnCategoryCDN: return @"cdn"; + default: return @"unknown"; + } +} +-(void)refreshTable { + // Sort by count desc + [_domains sortUsingComparator:^NSComparisonResult(NSDictionary *a,NSDictionary *b){ + return [b[@"count"] compare:a[@"count"]]; + }]; + [_table reloadData]; + NSInteger total=0,blocked=0; + for(NSDictionary *d in _domains){total+=((NSNumber*)d[@"count"]).integerValue;if([d[@"blocked"]boolValue])blocked++;} + _statsLabel.stringValue=[NSString stringWithFormat:@"%ld domains · %ld reqs · %ld blocked",(long)_domains.count,(long)total,(long)blocked]; +} +-(void)pushGraphUpdate { + NSMutableArray *nodeArr=[NSMutableArray array]; + NSMutableArray *edgeArr=[NSMutableArray array]; + // find the current page domain for the center node + NSString *center=@"this-page"; + for(NSDictionary *d in _domains){ + [nodeArr addObject:@{@"id":d[@"domain"],@"cat":d[@"cat"], + @"count":d[@"count"],@"blocked":d[@"blocked"],@"page":d[@"page"]}]; + if(![d[@"domain"] isEqualToString:center]) + [edgeArr addObject:@{@"s":center,@"t":d[@"domain"]}]; + } + if(nodeArr.count){ + [nodeArr insertObject:@{@"id":center,@"cat":@"first-party",@"count":@1,@"blocked":@NO,@"page":@""} atIndex:0]; + } + NSData *json=[NSJSONSerialization dataWithJSONObject:@{@"nodes":nodeArr,@"edges":edgeArr} options:0 error:nil]; + NSString *js=[NSString stringWithFormat:@"updateGraph(%@);", + [[NSString alloc]initWithData:json encoding:NSUTF8StringEncoding]]; + [_graphView evaluateJavaScript:js completionHandler:nil]; +} +-(void)clearAll:(id)s { + [_domains removeAllObjects]; + [_domainMap removeAllObjects]; + [BBNetworkMonitor.shared clear]; + [_table reloadData]; + _statsLabel.stringValue=@"Cleared"; + [_graphView evaluateJavaScript:@"nodes={};edges=[];" completionHandler:nil]; +} + +// ── Firewall panel ─────────────────────────────────────────────────────────── +-(void)openFirewall:(id)s { + NSWindow *fw=[[NSWindow alloc]initWithContentRect:NSMakeRect(0,0,480,400) + styleMask:(NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskResizable) + backing:NSBackingStoreBuffered defer:NO]; + fw.title=@"BearBrowser Firewall Rules"; + NSScrollView *sv=[[NSScrollView alloc]initWithFrame:fw.contentView.bounds]; + sv.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + sv.hasVerticalScroller=YES; + NSTableView *tv=[[NSTableView alloc]initWithFrame:sv.contentView.bounds]; + tv.rowHeight=22; + NSTableColumn *dc=[[NSTableColumn alloc]initWithIdentifier:@"dom"]; dc.title=@"Domain"; dc.width=250; [tv addTableColumn:dc]; + NSTableColumn *ac=[[NSTableColumn alloc]initWithIdentifier:@"act"]; ac.title=@"Rule"; ac.width=100; [tv addTableColumn:ac]; + NSTableColumn *xc=[[NSTableColumn alloc]initWithIdentifier:@"del"]; xc.title=@""; xc.width=60; [tv addTableColumn:xc]; + NSDictionary *rules=[BBFirewall.shared allRules]; + NSArray *ruleKeys=rules.allKeys; + // Simple static datasource closure + __block NSArray *keys=ruleKeys; + tv.dataSource=(id)[[NSObject alloc]init]; + // Can't easily do inline datasource; use a quick-and-dirty approach + // Just show an NSAlert with the rules list for now + [sv removeFromSuperview]; + NSMutableString *summary=[NSMutableString string]; + for(NSString *k in ruleKeys){ + NSInteger d=rules[k].integerValue; + [summary appendFormat:@"%@ → %@\n", k, d==BBFWAllow?@"ALLOW":d==BBFWBlock?@"BLOCK":@"ASK"]; + } + if(!summary.length)[summary appendString:@"No custom rules yet.\nDomain rules are set from the Network Monitor by clicking on a domain."]; + NSAlert *a=[[NSAlert alloc]init]; a.messageText=@"Firewall Rules"; + NSScrollView *scr=[[NSScrollView alloc]initWithFrame:NSMakeRect(0,0,400,200)]; + scr.hasVerticalScroller=YES; + NSTextView *txt=[[NSTextView alloc]initWithFrame:scr.contentView.bounds]; + txt.string=summary; txt.editable=NO; txt.font=[NSFont monospacedSystemFontOfSize:11 weight:NSFontWeightRegular]; + scr.documentView=txt; a.accessoryView=scr; + [a addButtonWithTitle:@"OK"]; + [a addButtonWithTitle:@"Clear All Rules"]; + [a beginSheetModalForWindow:_panel completionHandler:^(NSModalResponse r){ + if(r==NSAlertSecondButtonReturn){ + for(NSString *k in keys)[BBFirewall.shared set:BBFWAsk for:k]; + } + }]; +} + +// ── Packet capture panel ───────────────────────────────────────────────────── +-(void)toggleCapture:(id)s { + if(_capture && _capturePanel.visible){ + [_capture stop]; + [_capturePanel close]; _capturePanel=nil; + _monitorBtn.title=@"Start Capture"; return; + } + if(![BBPacketCapture available]){ + NSAlert *a=[[NSAlert alloc]init]; + a.messageText=@"Packet Capture Not Available"; + a.informativeText=@"Install Wireshark (tshark) or ensure tcpdump is accessible:\n\nbrew install wireshark\n\nYou may also need to run:\nsudo chmod +r /dev/bpf*\nor add yourself to the access_bpf group."; + [a addButtonWithTitle:@"OK"]; + [a addButtonWithTitle:@"Open Wireshark Website"]; + [a beginSheetModalForWindow:_panel completionHandler:^(NSModalResponse r){ + if(r==NSAlertSecondButtonReturn) + [[NSWorkspace sharedWorkspace]openURL:[NSURL URLWithString:@"https://www.wireshark.org/download.html"]]; + }]; return; + } + // Build capture output panel + NSRect pr=NSMakeRect(_panel.frame.origin.x+_panel.frame.size.width+8, + _panel.frame.origin.y,440,_panel.frame.size.height); + _capturePanel=[[NSPanel alloc]initWithContentRect:pr + styleMask:(NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskResizable) + backing:NSBackingStoreBuffered defer:NO]; + _capturePanel.title=@"Packet Capture"; + NSView *cpv=_capturePanel.contentView; + NSScrollView *csvw=[[NSScrollView alloc]initWithFrame:NSMakeRect(0,36,cpv.bounds.size.width,cpv.bounds.size.height-36)]; + csvw.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + csvw.hasVerticalScroller=YES; csvw.hasHorizontalScroller=YES; + _captureOutput=[[NSTextView alloc]initWithFrame:csvw.contentView.bounds]; + _captureOutput.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + _captureOutput.editable=NO; _captureOutput.backgroundColor=[NSColor colorWithWhite:0.1 alpha:1]; + _captureOutput.textColor=[NSColor colorWithRed:0.3 green:1 blue:0.3 alpha:1]; + _captureOutput.font=[NSFont monospacedSystemFontOfSize:10 weight:NSFontWeightRegular]; + csvw.documentView=_captureOutput; [cpv addSubview:csvw]; + NSButton *saveBtn=[NSButton buttonWithTitle:@"Save to Desktop" target:self action:@selector(saveCapture:)]; + saveBtn.frame=NSMakeRect(8,6,120,24); saveBtn.bezelStyle=NSBezelStyleRounded; [cpv addSubview:saveBtn]; + NSButton *wiresharkBtn=[NSButton buttonWithTitle:@"Open in Wireshark" target:self action:@selector(openInWireshark:)]; + wiresharkBtn.frame=NSMakeRect(136,6,140,24); wiresharkBtn.bezelStyle=NSBezelStyleRounded; [cpv addSubview:wiresharkBtn]; + [_capturePanel orderFront:nil]; + _monitorBtn.title=@"Stop Capture"; + __weak BBNetworkMapPanel *weakPanel=self; + [_capture startCapturingHost:@"" output:^(NSString*line){ + dispatch_async(dispatch_get_main_queue(),^{ + BBNetworkMapPanel *strong=weakPanel; if(!strong||!strong->_captureOutput) return; + NSString *appended=[NSString stringWithFormat:@"%@\n",line]; + NSAttributedString *as=[[NSAttributedString alloc]initWithString:appended + attributes:@{NSFontAttributeName:[NSFont monospacedSystemFontOfSize:10 weight:NSFontWeightRegular], + NSForegroundColorAttributeName:[NSColor colorWithRed:0.3 green:1 blue:0.3 alpha:1]}]; + [strong->_captureOutput.textStorage appendAttributedString:as]; + [strong->_captureOutput scrollToEndOfDocument:nil]; + }); + }]; +} +-(void)saveCapture:(id)s { NSURL *u=[_capture savePcapAndGetURL]; if(u)[[NSWorkspace sharedWorkspace]activateFileViewerSelectingURLs:@[u]]; } +-(void)openInWireshark:(id)s { + NSURL *u=[_capture savePcapAndGetURL]; if(!u) return; + if(![[NSWorkspace sharedWorkspace]openURL:u]){ + NSAlert *a=[[NSAlert alloc]init]; a.messageText=@"Wireshark Not Found"; + a.informativeText=@"File saved to Desktop. Open it manually in Wireshark."; + [a addButtonWithTitle:@"OK"]; [a runModal]; + } +} + +// ── WKScriptMessageHandler (mapAction from graph) ──────────────────────────── +-(void)userContentController:(WKUserContentController*)ucc didReceiveScriptMessage:(WKScriptMessage*)msg { + if(![msg.name isEqualToString:@"mapAction"]) return; + NSDictionary *body=[msg.body isKindOfClass:[NSDictionary class]]?msg.body:@{}; + NSString *domain=body[@"domain"]?:@""; + if(!domain.length) return; + BOOL currentlyBlocked=[body[@"blocked"] boolValue]; + NSAlert *a=[[NSAlert alloc]init]; + a.messageText=[NSString stringWithFormat:@"%@",domain]; + BBFirewallDecision current=[BBFirewall.shared decisionFor:domain]; + a.informativeText=[NSString stringWithFormat:@"Current rule: %@\n\nSet a firewall rule for this domain:", + current==BBFWAllow?@"Always allow":current==BBFWBlock?@"Always block":@"Default (follow blocklist)"]; + [a addButtonWithTitle:@"Block Always"]; + [a addButtonWithTitle:@"Allow Always"]; + [a addButtonWithTitle:@"Reset to Default"]; + [a beginSheetModalForWindow:_panel completionHandler:^(NSModalResponse r){ + if(r==NSAlertFirstButtonReturn) [BBFirewall.shared set:BBFWBlock for:domain]; + else if(r==NSAlertSecondButtonReturn)[BBFirewall.shared set:BBFWAllow for:domain]; + else [BBFirewall.shared set:BBFWAsk for:domain]; + }]; +} + +// ── NSTableViewDataSource / Delegate ───────────────────────────────────────── +-(NSInteger)numberOfRowsInTableView:(NSTableView*)tv { return _domains.count; } +-(id)tableView:(NSTableView*)tv objectValueForTableColumn:(NSTableColumn*)col row:(NSInteger)row { + if(row>=(NSInteger)_domains.count) return @""; + NSDictionary *d=_domains[row]; + if([col.identifier isEqualToString:@"domain"]) return d[@"domain"]; + if([col.identifier isEqualToString:@"count"]) return d[@"count"]; + if([col.identifier isEqualToString:@"status"]) return [d[@"blocked"] boolValue]?@"🔴":@"🟢"; + return @""; +} +-(NSView*)tableView:(NSTableView*)tv viewForTableColumn:(NSTableColumn*)col row:(NSInteger)row { + if(row>=(NSInteger)_domains.count) return nil; + NSDictionary *d=_domains[row]; + NSTextField *cell=[[NSTextField alloc]init]; + cell.editable=NO; cell.bordered=NO; cell.backgroundColor=[NSColor clearColor]; + cell.textColor=[NSColor colorWithWhite:0.85 alpha:1]; + cell.font=[NSFont systemFontOfSize:11]; + if([col.identifier isEqualToString:@"domain"]){ + cell.stringValue=d[@"domain"]?:@""; + // color by category + NSString *cat=d[@"cat"]?:@""; + if([cat isEqualToString:@"tracker"]) cell.textColor=[NSColor colorWithRed:0.89 green:0.29 blue:0.29 alpha:1]; + else if([cat isEqualToString:@"analytics"]) cell.textColor=[NSColor colorWithRed:0.94 green:0.62 blue:0.15 alpha:1]; + else if([cat isEqualToString:@"cdn"]) cell.textColor=[NSColor colorWithRed:0.22 green:0.54 blue:0.87 alpha:1]; + else if([cat isEqualToString:@"first-party"]) cell.textColor=[NSColor colorWithRed:0.11 green:0.62 blue:0.46 alpha:1]; + } else if([col.identifier isEqualToString:@"count"]){ + cell.alignment=NSTextAlignmentRight; cell.stringValue=[d[@"count"] stringValue]; + cell.textColor=[NSColor colorWithWhite:0.5 alpha:1]; + } else { + cell.stringValue=[d[@"blocked"] boolValue]?@"●":@"○"; + cell.textColor=[d[@"blocked"] boolValue]?[NSColor systemRedColor]:[NSColor colorWithWhite:0.3 alpha:1]; + } + return cell; +} +-(void)tableViewSelectionDidChange:(NSNotification*)n { + NSInteger row=_table.selectedRow; + if(row<0||row>=(NSInteger)_domains.count) return; + NSDictionary *d=_domains[row]; + NSString *dom=d[@"domain"]?:@""; + // highlight in graph + NSString *js=[NSString stringWithFormat:@"selected=nodes['%@'];", + [dom stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]]]; + [_graphView evaluateJavaScript:js completionHandler:nil]; +} +@end + +// ── BBSecurityPanel ─────────────────────────────────────────────────────────── +@interface BBSecurityPanel : NSObject ++(instancetype)shared; +-(void)show; +-(void)pushEvent:(BBSecurityEvent*)e; +@end + +@implementation BBSecurityPanel { + NSPanel *_panel; + NSTableView *_table; + NSMutableArray *_rows; + NSTextField *_badge; // live count +} ++(instancetype)shared{static BBSecurityPanel*s;static dispatch_once_t o;dispatch_once(&o,^{s=[[self alloc]init];});return s;} + +-(instancetype)init { + self=[super init]; _rows=[NSMutableArray array]; + // Wire up live feed from monitor + __weak BBSecurityPanel *weak=self; + BBSecurityMonitor.shared.onNewEvent=^(BBSecurityEvent *e){ + BBSecurityPanel *s=weak; if(!s) return; + [s pushEvent:e]; }; - BBAppendLine(BBProvenancePath(), BBJSON(event)); -} - -static void BBProposeAction(NSString *actionType, NSString *targetKind, NSString *targetLabel, NSString *targetURL, NSString *risk, NSString *decision, BOOL requiresApproval, NSString *reason) { - NSMutableDictionary *target = [@{ @"kind": targetKind ?: @"page" } mutableCopy]; - if (targetLabel.length > 0) { target[@"label"] = targetLabel; } - if (targetURL.length > 0) { target[@"url"] = targetURL; } - NSDictionary *action = @{ - @"schemaVersion": @"bearbrowser.policy_action.v1", - @"actionId": [@"act-" stringByAppendingString:BBRandomHex(16)], - @"timestamp": BBTimestamp(), - @"actionType": actionType, - @"requestedBy": @{ @"type": @"human", @"id": NSUserName() ?: @"local-user" }, - @"target": target, - @"risk": @{ @"level": risk, @"requiresUserApproval": @(requiresApproval), @"reason": reason }, - @"decision": @{ @"state": decision, @"decisionId": [@"local-" stringByAppendingString:BBRandomHex(8)], @"mode": @"local-default", @"reason": reason } + return self; +} + +-(void)show { + if (!_panel) [self buildPanel]; + // Sync existing events + NSArray *snap=[BBSecurityMonitor.shared snapshot]; + [_rows removeAllObjects]; + [_rows addObjectsFromArray:snap]; + [_table reloadData]; + if (_rows.count) [_table scrollRowToVisible:_rows.count-1]; + [_panel makeKeyAndOrderFront:nil]; +} + +-(void)buildPanel { + CGFloat W=820,H=540; + _panel=[[NSPanel alloc]initWithContentRect:NSMakeRect(200,200,W,H) + styleMask:NSWindowStyleMaskTitled|NSWindowStyleMaskResizable| + NSWindowStyleMaskClosable|NSWindowStyleMaskMiniaturizable + backing:NSBackingStoreBuffered defer:NO]; + _panel.title=@"Security Monitor"; + _panel.minSize=NSMakeSize(600,340); + + NSView *root=_panel.contentView; + + // Toolbar strip + NSView *bar=[[NSView alloc]initWithFrame:NSMakeRect(0,H-38,W,38)]; + bar.wantsLayer=YES; bar.layer.backgroundColor=[[NSColor colorWithWhite:0.13 alpha:1] CGColor]; + bar.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + [root addSubview:bar]; + + NSTextField *title=[NSTextField labelWithString:@"JS Security Monitor"]; + title.font=[NSFont boldSystemFontOfSize:12]; + title.textColor=[NSColor colorWithWhite:0.9 alpha:1]; + title.frame=NSMakeRect(12,8,200,20); + [bar addSubview:title]; + + _badge=[NSTextField labelWithString:@"0 events"]; + _badge.font=[NSFont monospacedSystemFontOfSize:11 weight:NSFontWeightRegular]; + _badge.textColor=[NSColor colorWithRed:0.9 green:0.6 blue:0.1 alpha:1]; + _badge.frame=NSMakeRect(220,8,200,20); + [bar addSubview:_badge]; + + NSButton *clr=[NSButton buttonWithTitle:@"Clear" target:self action:@selector(clearEvents:)]; + clr.frame=NSMakeRect(W-80,6,68,26); + clr.autoresizingMask=NSViewMinXMargin; + [bar addSubview:clr]; + + // Table + NSScrollView *scroll=[[NSScrollView alloc]initWithFrame:NSMakeRect(0,0,W,H-38)]; + scroll.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + scroll.hasVerticalScroller=YES; scroll.hasHorizontalScroller=NO; + scroll.autohidesScrollers=YES; + [root addSubview:scroll]; + + _table=[[NSTableView alloc]init]; + _table.dataSource=self; _table.delegate=self; + _table.usesAlternatingRowBackgroundColors=NO; + _table.backgroundColor=[NSColor colorWithWhite:0.10 alpha:1]; + _table.gridStyleMask=NSTableViewSolidHorizontalGridLineMask; + _table.gridColor=[NSColor colorWithWhite:0.18 alpha:1]; + _table.rowHeight=18; + _table.allowsMultipleSelection=NO; + + struct { NSString *id; NSString *title; CGFloat w; } cols[] = { + {@"sev", @"", 26}, + {@"time", @"Time", 66}, + {@"type", @"Type", 120}, + {@"page", @"Page", 200}, + {@"detail",@"Detail", 0}, // flexible }; - BBAppendLine(BBPolicyPath(), BBJSON(action)); + for (int i=0;i<5;i++) { + NSTableColumn *col=[[NSTableColumn alloc]initWithIdentifier:cols[i].id]; + col.title=cols[i].title; + col.minWidth=cols[i].w; col.width=cols[i].w; + if (i==4) col.resizingMask=NSTableColumnAutoresizingMask; + else col.resizingMask=NSTableColumnNoResizing; + [_table addTableColumn:col]; + } + scroll.documentView=_table; +} + +-(void)pushEvent:(BBSecurityEvent*)e { + dispatch_async(dispatch_get_main_queue(),^{ + [_rows addObject:e]; + if (_panel&&_panel.isVisible) { + [_table reloadData]; + [_table scrollRowToVisible:_rows.count-1]; + } + NSInteger crit=(NSInteger)[[_rows filteredArrayUsingPredicate: + [NSPredicate predicateWithFormat:@"severity >= %d",BBSecHigh]] count]; + _badge.stringValue=[NSString stringWithFormat:@"%ld events · %ld high/critical", + (long)_rows.count,(long)crit]; + if (e.severity>=BBSecHigh) + _badge.textColor=[NSColor colorWithRed:1 green:0.3 blue:0.3 alpha:1]; + }); +} + +-(void)clearEvents:(id)s { + [_rows removeAllObjects]; + [_table reloadData]; + _badge.stringValue=@"0 events"; + _badge.textColor=[NSColor colorWithRed:0.9 green:0.6 blue:0.1 alpha:1]; } -static BOOL BBMemoryLooksSensitive(NSString *text) { - NSArray *markers = @[@"password", @"secret", @"token", @"cookie", @"credential", @"payment"]; - NSString *lower = [text lowercaseString]; - for (NSString *marker in markers) { - if ([lower containsString:marker]) { return YES; } +// NSTableViewDataSource +-(NSInteger)numberOfRowsInTableView:(NSTableView*)tv { return _rows.count; } + +-(NSView*)tableView:(NSTableView*)tv viewForTableColumn:(NSTableColumn*)col row:(NSInteger)row { + if (row>=(NSInteger)_rows.count) return nil; + BBSecurityEvent *e=_rows[row]; + + NSTextField *cell=[tv makeViewWithIdentifier:col.identifier owner:self]; + if (!cell) { + cell=[NSTextField labelWithString:@""]; + cell.identifier=col.identifier; + cell.font=[NSFont monospacedSystemFontOfSize:11 weight:NSFontWeightRegular]; + cell.textColor=[NSColor colorWithWhite:0.85 alpha:1]; + } + + static NSColor *cCrit,*cHigh,*cMed,*cLow; + if (!cCrit) { + cCrit=[NSColor colorWithRed:1.0 green:0.3 blue:0.3 alpha:1]; + cHigh=[NSColor colorWithRed:1.0 green:0.6 blue:0.1 alpha:1]; + cMed =[NSColor colorWithRed:1.0 green:0.9 blue:0.2 alpha:1]; + cLow =[NSColor colorWithWhite:0.5 alpha:1]; + } + NSColor *sevColor = e.severity==BBSecCritical?cCrit: + e.severity==BBSecHigh?cHigh: + e.severity==BBSecMedium?cMed:cLow; + + if ([col.identifier isEqualToString:@"sev"]) { + cell.stringValue = e.severity==BBSecCritical?@"●": + e.severity==BBSecHigh?@"●": + e.severity==BBSecMedium?@"◑":@"○"; + cell.textColor=sevColor; + } else if ([col.identifier isEqualToString:@"time"]) { + NSDateFormatter *f=[[NSDateFormatter alloc]init]; + f.dateFormat=@"HH:mm:ss"; + cell.stringValue=[f stringFromDate:e.timestamp]; + cell.textColor=[NSColor colorWithWhite:0.5 alpha:1]; + } else if ([col.identifier isEqualToString:@"type"]) { + cell.stringValue=e.type?:@""; + cell.textColor=sevColor; + } else if ([col.identifier isEqualToString:@"page"]) { + NSString *pg=e.pageURL?:@""; + NSURL *u=[NSURL URLWithString:pg]; + cell.stringValue=u.host?:[pg lastPathComponent]?:pg; + cell.textColor=[NSColor colorWithWhite:0.65 alpha:1]; + } else { + cell.stringValue=e.detail?:@""; + cell.textColor=[NSColor colorWithWhite:0.78 alpha:1]; } + return cell; +} + +-(CGFloat)tableView:(NSTableView*)tv heightOfRow:(NSInteger)row { return 18; } + +-(BOOL)tableView:(NSTableView*)tv shouldSelectRow:(NSInteger)row { + if (row>=(NSInteger)_rows.count) return NO; + BBSecurityEvent *e=_rows[row]; + // Click → show full detail in NSAlert so developer can read the full payload + NSAlert *a=[[NSAlert alloc]init]; + a.messageText=[NSString stringWithFormat:@"%@ — %@",e.type,e.pageURL?:@""]; + a.informativeText=e.detail?:@"(no detail)"; + a.alertStyle=(e.severity>=BBSecHigh)?NSAlertStyleWarning:NSAlertStyleInformational; + [a addButtonWithTitle:@"OK"]; + [a runModal]; return NO; } +@end -static void BBCreateMemoryCandidate(NSString *text, NSString *sourceURL, NSString *sourceLabel) { - BOOL sensitive = BBMemoryLooksSensitive(text ?: @""); - NSString *memoryId = [@"mem-" stringByAppendingString:BBRandomHex(16)]; - NSString *storedText = sensitive ? @"" : (text ?: @""); - NSString *payloadClass = sensitive ? @"secret-blocked" : @"metadata"; - NSMutableDictionary *source = [@{ @"kind": @"page" } mutableCopy]; - if (sourceURL.length > 0) { source[@"url"] = sourceURL; } - if (sourceLabel.length > 0) { source[@"label"] = sourceLabel; } - NSDictionary *memory = @{ - @"schemaVersion": @"bearbrowser.memory_candidate.v1", - @"memoryId": memoryId, - @"timestamp": BBTimestamp(), - @"product": @"BearBrowser", - @"state": @"candidate", - @"actor": @{ @"type": @"human", @"id": NSUserName() ?: @"local-user" }, - @"source": source, - @"classification": @{ - @"payloadClass": payloadClass, - @"secretLikeDetected": @(sensitive), - @"persistentWriteRequiresApproval": @YES - }, - @"text": storedText, - @"policy": @{ - @"decision": @"hold", - @"decisionId": [@"local-" stringByAppendingString:BBRandomHex(8)], - @"mode": @"local-default", - @"reason": @"Memory candidates must be previewed and explicitly committed or rejected." +// ── BBAgentServer ───────────────────────────────────────────────────────────── +// +// Secure local Unix socket that lets agent processes (Claude Code, agent-plane, +// sidecar scripts) observe and propose browser actions. +// +// Security model — matches agent-sidecar/contract.yaml: +// • Unix socket at BBSupportDir()/agent.sock — OS enforces 0600, owner-only +// • Per-session token (256-bit) written to BBSupportDir()/.agent-token (0600) +// • Every command classified by risk level from bearbrowser-propose-action defaults +// • "observe.*" actions execute immediately (no mutation, no approval needed) +// • "propose.*" actions go through BBProposeAction + native approval sheet +// • All commands logged via BBEmitEvent with actor.type = "agent" +// • credentials, cookies, secrets are never returned — redacted at boundary +// +// Wire protocol: newline-delimited JSON +// → {"v":1,"token":"","action":"observe.url"} +// ← {"status":"ok","result":{"url":"https://..."}} +// +// → {"v":1,"token":"","action":"propose.navigate","url":"https://..."} +// ← {"status":"hold","actionId":"act-xxx","message":"Awaiting user approval"} +// (then after user approves/denies, a second line is sent) +// ← {"status":"ok","result":{"navigated":true}} |OR| {"status":"denied"} + +// Minimal protocol so BBAgentServer doesn't depend on the full BBDelegate @interface +@protocol BBAgentBrowserDelegate +@property(readonly) WKWebView *webView; +@property(readonly) NSWindow *window; +@property(readonly) NSArray *tabs; +-(void)addTabPrivate:(BOOL)isPrivate; +@end + +// Risk classification matching bearbrowser-propose-action.py DEFAULTS + AGENT_RUNTIME_OVERRIDES +typedef NS_ENUM(NSInteger, BBAgentRisk) { + BBAgentRiskObserve = 0, // no mutation, no approval — immediate + BBAgentRiskLow, // mutation allowed, but agent-runtime → hold + BBAgentRiskHigh, // holds, must approve + BBAgentRiskCritical, // always deny for agent-runtime +}; + +static BBAgentRisk BBRiskForAction(NSString *action) { + if ([action hasPrefix:@"observe."]) return BBAgentRiskObserve; + if ([action isEqualToString:@"propose.navigate"]) return BBAgentRiskLow; + if ([action isEqualToString:@"propose.new_tab"]) return BBAgentRiskLow; + if ([action isEqualToString:@"propose.evaluate_js"])return BBAgentRiskHigh; + if ([action isEqualToString:@"propose.fill_form"]) return BBAgentRiskHigh; + if ([action isEqualToString:@"propose.click"]) return BBAgentRiskHigh; + if ([action isEqualToString:@"propose.screenshot"]) return BBAgentRiskLow; + if ([action isEqualToString:@"propose.credential"]) return BBAgentRiskCritical; + return BBAgentRiskHigh; // unknown → high +} + +@interface BBAgentServer : NSObject ++(instancetype)shared; +-(void)startWithDelegate:(id)delegate; +-(void)stop; +-(NSString*)tokenPath; +-(NSString*)socketPath; +@end + +@implementation BBAgentServer { + __weak id _del; + int _serverFd; + dispatch_source_t _acceptSource; + NSString *_token; +} + ++(instancetype)shared{static BBAgentServer*s;static dispatch_once_t o;dispatch_once(&o,^{s=[[self alloc]init];});return s;} + +-(NSString*)socketPath { return [BBSupportDir() stringByAppendingPathComponent:@"agent.sock"]; } +-(NSString*)tokenPath { return [BBSupportDir() stringByAppendingPathComponent:@".agent-token"]; } + +-(void)startWithDelegate:(id)delegate { + _del=delegate; + // Generate per-session token + _token=[self generateToken]; + [self writeToken:_token]; + [self listenOnSocket]; +} + +-(NSString*)generateToken { + uint8_t buf[32]; (void)SecRandomCopyBytes(kSecRandomDefault,32,buf); + NSMutableString *hex=[NSMutableString stringWithCapacity:64]; + for(int i=0;i<32;i++)[hex appendFormat:@"%02x",buf[i]]; + return hex; +} + +-(void)writeToken:(NSString*)token { + [[NSFileManager defaultManager]createDirectoryAtPath:BBSupportDir() + withIntermediateDirectories:YES attributes:nil error:nil]; + NSString *p=self.tokenPath; + [token writeToFile:p atomically:YES encoding:NSUTF8StringEncoding error:nil]; + // 0600 — owner read/write only + [[NSFileManager defaultManager]setAttributes:@{NSFilePosixPermissions:@(0600)} + ofItemAtPath:p error:nil]; +} + +-(void)listenOnSocket { + // Remove stale socket + [[NSFileManager defaultManager]removeItemAtPath:self.socketPath error:nil]; + + _serverFd=socket(AF_UNIX,SOCK_STREAM,0); + if(_serverFd<0) return; + + struct sockaddr_un addr; + memset(&addr,0,sizeof(addr)); + addr.sun_family=AF_UNIX; + strlcpy(addr.sun_path,self.socketPath.UTF8String,sizeof(addr.sun_path)); + + if(bind(_serverFd,(struct sockaddr*)&addr,sizeof(addr))<0){close(_serverFd);return;} + // 0600 on the socket file + [[NSFileManager defaultManager]setAttributes:@{NSFilePosixPermissions:@(0600)} + ofItemAtPath:self.socketPath error:nil]; + + if(listen(_serverFd,8)<0){close(_serverFd);return;} + + _acceptSource=dispatch_source_create(DISPATCH_SOURCE_TYPE_READ,_serverFd,0, + dispatch_get_global_queue(QOS_CLASS_UTILITY,0)); + __weak BBAgentServer *weak=self; + dispatch_source_set_event_handler(_acceptSource,^{ + BBAgentServer *s=weak; if(!s) return; + int clientFd=accept(s->_serverFd,NULL,NULL); + if(clientFd>=0)[s handleClient:clientFd]; + }); + dispatch_resume(_acceptSource); +} + +-(void)handleClient:(int)fd { + // Read until newline (one command per connection — simple, no framing complexity) + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY,0),^{ + NSMutableData *buf=[NSMutableData data]; + uint8_t byte; ssize_t n; + while((n=read(fd,&byte,1))>0){ + if(byte=='\n') break; + [buf appendBytes:&byte length:1]; + if(buf.length>65536) break; // cap at 64KB } - }; - BBAppendLine(BBMemoryPath(), BBJSON(memory)); - BBEmitEvent(@"memory.candidate_created", @"hold", @"Native shell created a held memory candidate.", @{ @"memoryId": memoryId, @"url": sourceURL ?: @"" }); + if(!buf.length){close(fd);return;} + NSDictionary *cmd=[NSJSONSerialization JSONObjectWithData:buf options:0 error:nil]; + [self dispatch:cmd fd:fd]; + }); +} + +// Validate token and version, then route +-(void)dispatch:(NSDictionary*)cmd fd:(int)fd { + if(![cmd isKindOfClass:[NSDictionary class]]){[self respond:fd status:@"error" result:@{@"message":@"invalid JSON"}];return;} + if(![cmd[@"v"] isEqual:@1]){[self respond:fd status:@"error" result:@{@"message":@"unsupported version"}];return;} + NSString *tok=cmd[@"token"]?:@""; + if(![tok isEqualToString:_token]){ + [self respond:fd status:@"denied" result:@{@"message":@"invalid token"}]; + BBEmitEventStatic(@"security.agent_auth_failure",@"deny",@"Agent connection with bad token.",@{}); + close(fd); return; + } + NSString *action=cmd[@"action"]?:@""; + BBAgentRisk risk=BBRiskForAction(action); + if(risk==BBAgentRiskCritical){ + [self respond:fd status:@"denied" result:@{@"message":@"action denied by policy — credential access not available to agent-runtime"}]; + BBEmitEventStatic([NSString stringWithFormat:@"automation.action_denied"],@"deny", + [NSString stringWithFormat:@"Critical-risk agent action '%@' denied.",action],@{@"action":action}); + close(fd); return; + } + [self route:action cmd:cmd fd:fd risk:risk]; } -@interface BBDelegate : NSObject +-(void)route:(NSString*)action cmd:(NSDictionary*)cmd fd:(int)fd risk:(BBAgentRisk)risk { + id d=_del; if(!d){[self respond:fd status:@"error" result:@{@"message":@"browser not ready"}];close(fd);return;} + + BBEmitEventStatic(@"automation.observed",@"observe", + [NSString stringWithFormat:@"Agent command received: %@",action], + @{@"action":action,@"risk":@[@"observe",@"low",@"high",@"critical"][risk]}); + + // ── Observe actions — no mutation, immediate response ────────────────────── + if([action isEqualToString:@"observe.url"]) { + dispatch_async(dispatch_get_main_queue(),^{ + NSString *u=d.webView.URL.absoluteString?:@""; + [self respond:fd status:@"ok" result:@{@"url":u}]; close(fd); + }); return; + } + if([action isEqualToString:@"observe.title"]) { + dispatch_async(dispatch_get_main_queue(),^{ + NSString *t=d.webView.title?:@""; + [self respond:fd status:@"ok" result:@{@"title":t}]; close(fd); + }); return; + } + if([action isEqualToString:@"observe.tabs"]) { + dispatch_async(dispatch_get_main_queue(),^{ + NSMutableArray *tabs=[NSMutableArray array]; + for(BBTab *t in d.tabs) + [tabs addObject:@{@"url":t.webView.URL.absoluteString?:@"", + @"title":t.title?:@"",@"private":@(t.isPrivate)}]; + [self respond:fd status:@"ok" result:@{@"tabs":tabs}]; close(fd); + }); return; + } + if([action isEqualToString:@"observe.page_text"]) { + dispatch_async(dispatch_get_main_queue(),^{ + [d.webView evaluateJavaScript: + @"(document.body&&document.body.innerText?document.body.innerText:'').slice(0,32000)" + completionHandler:^(id r,NSError*e){ + NSString *text=[r isKindOfClass:[NSString class]]?r:@""; + [self respond:fd status:@"ok" result:@{@"text":text}]; close(fd); + }]; + }); return; + } + if([action isEqualToString:@"observe.page_html"]) { + dispatch_async(dispatch_get_main_queue(),^{ + [d.webView evaluateJavaScript:@"document.documentElement.outerHTML.slice(0,256000)" + completionHandler:^(id r,NSError*e){ + NSString *html=[r isKindOfClass:[NSString class]]?r:@""; + [self respond:fd status:@"ok" result:@{@"html":html}]; close(fd); + }]; + }); return; + } + if([action isEqualToString:@"observe.network_events"]) { + dispatch_async(dispatch_get_main_queue(),^{ + NSArray *snap=[BBNetworkMonitor.shared snapshot]; + NSMutableArray *out=[NSMutableArray array]; + NSInteger lim=MIN((NSInteger)snap.count,200); + for(NSInteger i=snap.count-lim;i<(NSInteger)snap.count;i++){ + BBConnectionRecord *r=snap[i]; + [out addObject:@{@"domain":r.domain,@"type":r.resourceType, + @"blocked":@(r.blocked),@"ts":@(r.timestamp.timeIntervalSince1970)}]; + } + [self respond:fd status:@"ok" result:@{@"events":out}]; close(fd); + }); return; + } + if([action isEqualToString:@"observe.provenance_tail"]) { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY,0),^{ + // Return last 50 lines of provenance JSONL — redacted values only + NSString *path=BBProvenancePath(); + NSString *raw=[NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil]?:@""; + NSArray *lines=[raw componentsSeparatedByString:@"\n"]; + NSInteger start=MAX(0,(NSInteger)lines.count-50); + NSArray *tail=[lines subarrayWithRange:NSMakeRange(start,lines.count-start)]; + [self respond:fd status:@"ok" result:@{@"lines":tail}]; close(fd); + }); return; + } + if([action isEqualToString:@"observe.security_events"]) { + dispatch_async(dispatch_get_main_queue(),^{ + NSArray *snap=[BBSecurityMonitor.shared snapshot]; + NSInteger lim=MIN((NSInteger)snap.count,100); + NSMutableArray *out=[NSMutableArray array]; + for(NSInteger i=snap.count-lim;i<(NSInteger)snap.count;i++){ + BBSecurityEvent *e=snap[i]; + [out addObject:@{@"type":e.type,@"page":e.pageURL?:@"", + @"detail":e.detail?:@"", + @"severity":@[@"low",@"medium",@"high",@"critical"][e.severity], + @"ts":@(e.timestamp.timeIntervalSince1970)}]; + } + [self respond:fd status:@"ok" result:@{@"events":out}]; close(fd); + }); return; + } + if([action isEqualToString:@"observe.dom_snapshot"]) { + dispatch_async(dispatch_get_main_queue(),^{ + NSString *domJS= + @"(function(){" + @"function snap(el,depth){" + @" if(depth>4||!el)return null;" + @" var r={tag:(el.tagName||'#text').toLowerCase()};" + @" if(el.id)r.id=el.id;" + @" if(el.className&&typeof el.className==='string')r.cls=el.className.slice(0,60);" + @" if(el.href)r.href=el.href;" + @" if(el.src)r.src=el.src;" + @" if(el.type)r.type=el.type;" + @" if(el.name)r.name=el.name;" + @" if(el.getAttribute&&el.getAttribute('role'))r.role=el.getAttribute('role');" + @" if(el.getAttribute&&el.getAttribute('aria-label'))r.label=el.getAttribute('aria-label');" + @" var txt=(el.innerText||el.textContent||'').trim().slice(0,120);" + @" if(txt)r.text=txt;" + @" var kids=Array.from(el.children||[]).slice(0,12)" + @" .map(function(c){return snap(c,depth+1)}).filter(Boolean);" + @" if(kids.length)r.children=kids;" + @" return r;}" + @"return JSON.stringify(snap(document.body,0));})()"; + [d.webView evaluateJavaScript:domJS completionHandler:^(id r,NSError*e){ + NSString *json=[r isKindOfClass:[NSString class]]?r:@"{}"; + [self respond:fd status:@"ok" result:@{@"dom":json}]; close(fd); + }]; + }); return; + } + + // ── Propose actions — require user approval ──────────────────────────────── + NSString *actionId=[NSString stringWithFormat:@"act-%@",BBRandomHexStatic(16)]; + // Immediately ACK with "hold" — user approval shown on main thread + [self respond:fd status:@"hold" result:@{@"actionId":actionId, + @"message":@"awaiting user approval"}]; + + dispatch_async(dispatch_get_main_queue(),^{ + [self presentApproval:action cmd:cmd actionId:actionId fd:fd delegate:d]; + }); +} + +-(void)presentApproval:(NSString*)action cmd:(NSDictionary*)cmd + actionId:(NSString*)actionId fd:(int)fd delegate:(id)d { + NSString *detail=@""; + if([action isEqualToString:@"propose.navigate"]) + detail=[NSString stringWithFormat:@"Navigate to: %@",cmd[@"url"]?:@"?"]; + else if([action isEqualToString:@"propose.evaluate_js"]) + {NSString *sc=cmd[@"script"]?:@"?"; + detail=[NSString stringWithFormat:@"Run JS:\n%@",sc.length>200?[sc substringToIndex:200]:sc];} + else if([action isEqualToString:@"propose.fill_form"]) + detail=@"Fill form fields on current page"; + else if([action isEqualToString:@"propose.click"]) + detail=[NSString stringWithFormat:@"Click element: %@",cmd[@"selector"]?:@"?"]; + else if([action isEqualToString:@"propose.new_tab"]) + detail=@"Open a new tab"; + else if([action isEqualToString:@"propose.screenshot"]) + detail=@"Capture a screenshot of the current page"; + + NSAlert *a=[[NSAlert alloc]init]; + a.messageText=@"Agent Action Request"; + a.informativeText=[NSString stringWithFormat:@"An agent wants to:\n\n%@\n\nAllow this action?",detail]; + a.alertStyle=NSAlertStyleWarning; + [a addButtonWithTitle:@"Allow"]; [a addButtonWithTitle:@"Deny"]; + [a beginSheetModalForWindow:d.window completionHandler:^(NSModalResponse r){ + BOOL allowed=(r==NSAlertFirstButtonReturn); + BBEmitEventStatic(allowed?@"automation.action_approved":@"automation.action_denied", + allowed?@"allow":@"deny", + [NSString stringWithFormat:@"User %@ agent action '%@'",allowed?@"approved":@"denied",action], + @{@"actionId":actionId,@"action":action}); + if(!allowed){ + [self respond:fd status:@"denied" result:@{@"actionId":actionId,@"message":@"denied by user"}]; + close(fd); return; + } + [self execute:action cmd:cmd fd:fd actionId:actionId delegate:d]; + }]; +} + +-(void)execute:(NSString*)action cmd:(NSDictionary*)cmd + fd:(int)fd actionId:(NSString*)actionId delegate:(id)d { + if([action isEqualToString:@"propose.navigate"]) { + NSString *url=cmd[@"url"]?:@""; + NSURL *u=[NSURL URLWithString:url]; + if(!u||(!u.scheme)){[self respond:fd status:@"error" result:@{@"message":@"invalid url"}];close(fd);return;} + [d.webView loadRequest:[NSURLRequest requestWithURL:u]]; + [self respond:fd status:@"ok" result:@{@"actionId":actionId,@"navigated":@YES}]; close(fd); + } else if([action isEqualToString:@"propose.new_tab"]) { + [d addTabPrivate:NO]; + [self respond:fd status:@"ok" result:@{@"actionId":actionId}]; close(fd); + } else if([action isEqualToString:@"propose.evaluate_js"]) { + NSString *script=cmd[@"script"]?:@""; + [d.webView evaluateJavaScript:script completionHandler:^(id r,NSError*e){ + if(e){[self respond:fd status:@"error" result:@{@"message":e.localizedDescription}];} + else { + // Stringify result — never return DOM references or live objects + NSString *res=[r isKindOfClass:[NSString class]]?r: + ([r isKindOfClass:[NSNumber class]]?[r stringValue]: + ([r isKindOfClass:[NSDictionary class]]||[r isKindOfClass:[NSArray class]]? + ([[NSString alloc]initWithData:[NSJSONSerialization dataWithJSONObject:r options:0 error:nil] + encoding:NSUTF8StringEncoding]?:@"[object]"):@"null")); + [self respond:fd status:@"ok" result:@{@"actionId":actionId,@"result":res}]; + } + close(fd); + }]; + } else if([action isEqualToString:@"propose.screenshot"]) { + WKSnapshotConfiguration *cfg=[[WKSnapshotConfiguration alloc]init]; + [d.webView takeSnapshotWithConfiguration:cfg completionHandler:^(NSImage *img,NSError*e){ + if(!img||e){[self respond:fd status:@"error" result:@{@"message":e.localizedDescription?:@"snapshot failed"}];close(fd);return;} + NSBitmapImageRep *rep=[[NSBitmapImageRep alloc]initWithData:img.TIFFRepresentation]; + NSData *png=[rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; + NSString *b64=[png base64EncodedStringWithOptions:0]; + [self respond:fd status:@"ok" result:@{@"actionId":actionId,@"png_base64":b64?:@""}]; close(fd); + }]; + } else { + [self respond:fd status:@"error" result:@{@"message":@"unknown action"}]; close(fd); + } +} + +-(void)respond:(int)fd status:(NSString*)status result:(NSDictionary*)result { + NSMutableDictionary *r=[NSMutableDictionary dictionaryWithDictionary:result]; + r[@"status"]=status; + NSData *json=[NSJSONSerialization dataWithJSONObject:r options:0 error:nil]; + if(!json) return; + NSMutableData *line=[NSMutableData dataWithData:json]; + uint8_t nl='\n'; [line appendBytes:&nl length:1]; + write(fd,line.bytes,line.length); +} + +-(void)stop { + if(_acceptSource){dispatch_source_cancel(_acceptSource);_acceptSource=nil;} + if(_serverFd>0){close(_serverFd);_serverFd=0;} + [[NSFileManager defaultManager]removeItemAtPath:self.socketPath error:nil]; +} + +// Static wrappers so BBAgentServer can call helpers defined at file scope +static void BBEmitEventStatic(NSString*type,NSString*dec,NSString*reason,NSDictionary*payload){ + BBEmitEvent(type,dec,reason,payload); +} +static NSString* BBRandomHexStatic(NSUInteger n){return BBRandomHex(n);} + +@end + +// ── BBDelegate ──────────────────────────────────────────────────────────────── +@interface BBDelegate : NSObject @property(strong) NSWindow *window; -@property(strong) WKWebView *webView; -@property(strong) NSTextField *address; +@property(strong) NSMutableArray *tabs; +@property(strong) NSMutableArray *closedTabURLs; +@property(assign) NSInteger activeTabIndex; +@property(strong) NSView *root; +@property(strong) NSView *toolbarBg; +@property(strong) BBTabBarView *tabBarView; +@property(strong) NSTextField *address; +@property(strong) NSButton *backButton, *forwardButton, *reloadButton, *securityButton; +@property(strong) NSProgressIndicator *progressBar; +@property(strong) BBFindBar *findBar; +@property(assign) BOOL findBarVisible; +@property(strong) WKContentRuleList *contentRuleList; +// New +@property(strong) BBDownloadPanel *downloadPanel; +@property(strong) BBAddressDropdown *addressDropdown; +@property(strong) NSView *bookmarksBar; +@property(assign) BOOL bookmarksBarVisible; +@property(strong) NSCache *dnsBlockCache; // Quad9 NXDOMAIN results +@property(strong) NSMutableSet *decoyViews; // script-popup honeypots — kept alive briefly +@property(assign) NSTimeInterval lastUserGestureTime; // tracks real input events for popup gating +@property(assign) SecTrustRef currentTrust; // TLS trust for current page cert inspector @end @implementation BBDelegate -- (void)applicationDidFinishLaunching:(NSNotification *)notification { - BBLog(@"BearBrowser native WebKit shell start"); - BBEmitEvent(@"app.launch", @"allow", @"Native BearBrowser shell launched.", @{ @"bundleId": @"dev.sourceos.BearBrowser" }); +- (BBTab *)activeTab { return (self.activeTabIndex<(NSInteger)self.tabs.count)?self.tabs[self.activeTabIndex]:nil; } +- (WKWebView *)webView { return self.activeTab.webView; } +- (NSString *)currentURLString { return self.activeTab.webView.URL.absoluteString?:@"bearbrowser://start"; } + +// ── Menu ────────────────────────────────────────────────────────────────────── +- (void)buildMenu { + NSMenu *bar=[[NSMenu alloc]init]; [NSApp setMainMenu:bar]; + void(^mi)(NSMenu*,NSString*,SEL,NSString*,NSUInteger)=^(NSMenu *m,NSString *t,SEL a,NSString *k,NSUInteger mod){ + NSMenuItem *i=[m addItemWithTitle:t action:a keyEquivalent:k]; if(mod) i.keyEquivalentModifierMask=mod; + }; + // BearBrowser + NSMenuItem *appI=[[NSMenuItem alloc]init]; [bar addItem:appI]; + NSMenu *appM=[[NSMenu alloc]initWithTitle:@"BearBrowser"]; appI.submenu=appM; + [appM addItemWithTitle:@"About BearBrowser" action:@selector(orderFrontStandardAboutPanel:) keyEquivalent:@""]; + [appM addItem:[NSMenuItem separatorItem]]; + mi(appM,@"Search Engine…",@selector(openSearchPreferences:),@",",NSEventModifierFlagCommand); + [appM addItem:[NSMenuItem separatorItem]]; + mi(appM,@"Hide BearBrowser",@selector(hide:),@"h",NSEventModifierFlagCommand); + [appM addItemWithTitle:@"Hide Others" action:@selector(hideOtherApplications:) keyEquivalent:@"h"].keyEquivalentModifierMask=NSEventModifierFlagCommand|NSEventModifierFlagOption; + [appM addItemWithTitle:@"Show All" action:@selector(unhideAllApplications:) keyEquivalent:@""]; + [appM addItem:[NSMenuItem separatorItem]]; + mi(appM,@"Quit BearBrowser",@selector(terminate:),@"q",NSEventModifierFlagCommand); + // File + NSMenuItem *fileI=[[NSMenuItem alloc]init]; [bar addItem:fileI]; + NSMenu *fileM=[[NSMenu alloc]initWithTitle:@"File"]; fileI.submenu=fileM; + mi(fileM,@"New Tab",@selector(newTab:),@"t",NSEventModifierFlagCommand); + mi(fileM,@"New Private Tab",@selector(newPrivateTab:),@"t",NSEventModifierFlagCommand|NSEventModifierFlagShift); + mi(fileM,@"New Window",@selector(newWindow:),@"n",NSEventModifierFlagCommand); + [fileM addItem:[NSMenuItem separatorItem]]; + mi(fileM,@"Open File…",@selector(openFile:),@"o",NSEventModifierFlagCommand); + [fileM addItem:[NSMenuItem separatorItem]]; + mi(fileM,@"Add Bookmark…",@selector(addBookmark:),@"d",NSEventModifierFlagCommand); + mi(fileM,@"Show Bookmarks Bar",@selector(toggleBookmarksBar:),@"b",NSEventModifierFlagCommand|NSEventModifierFlagShift); + [fileM addItem:[NSMenuItem separatorItem]]; + mi(fileM,@"Close Tab",@selector(closeCurrentTab:),@"w",NSEventModifierFlagCommand); + mi(fileM,@"Reopen Closed Tab",@selector(reopenClosedTab:),@"t",NSEventModifierFlagCommand|NSEventModifierFlagShift|NSEventModifierFlagOption); + [fileM addItem:[NSMenuItem separatorItem]]; + mi(fileM,@"Save Page As…",@selector(savePage:),@"s",NSEventModifierFlagCommand); + mi(fileM,@"Print…",@selector(printPage:),@"p",NSEventModifierFlagCommand); + // Edit + NSMenuItem *editI=[[NSMenuItem alloc]init]; [bar addItem:editI]; + NSMenu *editM=[[NSMenu alloc]initWithTitle:@"Edit"]; editI.submenu=editM; + mi(editM,@"Undo",@selector(undo:),@"z",NSEventModifierFlagCommand); + [editM addItemWithTitle:@"Redo" action:@selector(redo:) keyEquivalent:@"z"].keyEquivalentModifierMask=NSEventModifierFlagCommand|NSEventModifierFlagShift; + [editM addItem:[NSMenuItem separatorItem]]; + mi(editM,@"Cut",@selector(cut:),@"x",NSEventModifierFlagCommand); + mi(editM,@"Copy",@selector(copy:),@"c",NSEventModifierFlagCommand); + mi(editM,@"Paste",@selector(paste:),@"v",NSEventModifierFlagCommand); + mi(editM,@"Paste and Go",@selector(pasteAndGo:),@"v",NSEventModifierFlagCommand|NSEventModifierFlagShift); + mi(editM,@"Select All",@selector(selectAll:),@"a",NSEventModifierFlagCommand); + [editM addItem:[NSMenuItem separatorItem]]; + mi(editM,@"Find on Page…",@selector(toggleFind:),@"f",NSEventModifierFlagCommand); + // View + NSMenuItem *viewI=[[NSMenuItem alloc]init]; [bar addItem:viewI]; + NSMenu *viewM=[[NSMenu alloc]initWithTitle:@"View"]; viewI.submenu=viewM; + mi(viewM,@"Reload Page",@selector(reloadOrStop:),@"r",NSEventModifierFlagCommand); + mi(viewM,@"Hard Reload (Skip Cache)",@selector(hardReload:),@"r",NSEventModifierFlagCommand|NSEventModifierFlagShift); + mi(viewM,@"Focus Address Bar",@selector(focusAddressBar:),@"l",NSEventModifierFlagCommand); + [viewM addItem:[NSMenuItem separatorItem]]; + mi(viewM,@"Zoom In",@selector(zoomIn:),@"+",NSEventModifierFlagCommand); + mi(viewM,@"Zoom Out",@selector(zoomOut:),@"-",NSEventModifierFlagCommand); + mi(viewM,@"Actual Size",@selector(zoomReset:),@"0",NSEventModifierFlagCommand); + [viewM addItem:[NSMenuItem separatorItem]]; + mi(viewM,@"View Page Source",@selector(viewSource:),@"u",NSEventModifierFlagCommand); + mi(viewM,@"Developer Tools",@selector(openDevTools:),@"i",NSEventModifierFlagCommand|NSEventModifierFlagOption); + [viewM addItem:[NSMenuItem separatorItem]]; + mi(viewM,@"Enter Full Screen",@selector(toggleFullScreen:),@"f",NSEventModifierFlagCommand|NSEventModifierFlagControl); + [viewM addItem:[NSMenuItem separatorItem]]; + mi(viewM,@"History",@selector(showHistory:),@"y",NSEventModifierFlagCommand); + mi(viewM,@"Downloads",@selector(toggleDownloadPanel:),@"j",NSEventModifierFlagCommand); + [viewM addItem:[NSMenuItem separatorItem]]; + mi(viewM,@"Network Monitor",@selector(openNetworkMonitor:),@"m",NSEventModifierFlagCommand|NSEventModifierFlagShift); + [viewM addItem:[NSMenuItem separatorItem]]; + mi(viewM,@"Read Aloud",@selector(readAloud:),@"r",NSEventModifierFlagCommand|NSEventModifierFlagOption); + // History + NSMenuItem *histI=[[NSMenuItem alloc]init]; [bar addItem:histI]; + NSMenu *histM=[[NSMenu alloc]initWithTitle:@"History"]; histI.submenu=histM; + mi(histM,@"Back",@selector(goBack:),@"[",NSEventModifierFlagCommand); + mi(histM,@"Forward",@selector(goForward:),@"]",NSEventModifierFlagCommand); + // Window + NSMenuItem *winI=[[NSMenuItem alloc]init]; [bar addItem:winI]; + NSMenu *winM=[[NSMenu alloc]initWithTitle:@"Window"]; winI.submenu=winM; + [NSApp setWindowsMenu:winM]; + mi(winM,@"Minimize",@selector(performMiniaturize:),@"m",NSEventModifierFlagCommand); + [winM addItemWithTitle:@"Zoom" action:@selector(performZoom:) keyEquivalent:@""]; + [winM addItem:[NSMenuItem separatorItem]]; + mi(winM,@"Next Tab",@selector(nextTab:),@"\t",NSEventModifierFlagControl); + [winM addItemWithTitle:@"Previous Tab" action:@selector(prevTab:) keyEquivalent:@"\t"].keyEquivalentModifierMask=NSEventModifierFlagControl|NSEventModifierFlagShift; + [winM addItem:[NSMenuItem separatorItem]]; + for (NSInteger i=1;i<=9;i++) { + NSMenuItem *ti=[winM addItemWithTitle:[NSString stringWithFormat:@"Tab %ld",(long)i] + action:@selector(switchToTabByMenuItem:) + keyEquivalent:[NSString stringWithFormat:@"%ld",(long)i]]; + ti.keyEquivalentModifierMask=NSEventModifierFlagCommand; ti.tag=i-1; + } + [winM addItem:[NSMenuItem separatorItem]]; + [winM addItemWithTitle:@"Bring All to Front" action:@selector(arrangeInFront:) keyEquivalent:@""]; +} + +// ── App launch ──────────────────────────────────────────────────────────────── +- (void)applicationDidFinishLaunching:(NSNotification *)n { + BBLog(@"BearBrowser start"); + BBEmitEvent(@"app.launch",@"allow",@"Native shell launched.",@{@"bundleId":@"dev.sourceos.BearBrowser"}); + [[BBAgentServer shared] startWithDelegate:self]; + BBLog([NSString stringWithFormat:@"Agent socket: %@",[BBAgentServer shared].socketPath]); + BBLog([NSString stringWithFormat:@"Agent token: %@",[BBAgentServer shared].tokenPath]); [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + [self buildMenu]; + self.closedTabURLs=[NSMutableArray array]; + // Compile content rules on a background queue; tabs added after compilation get rules applied. + // Tabs opened immediately (below) get rules applied once compile finishes via the shared property. + [BBContentBlocker loadRulesInto:[[WKWebViewConfiguration alloc]init] completion:^{ + // Store compiled rule list for future webviews. We re-compile to get the actual list object. + WKContentRuleListStore *store=[WKContentRuleListStore defaultStore]; + [store lookUpContentRuleListForIdentifier:@"bb-baseline" completionHandler:^(WKContentRuleList *list, NSError *e){ + if (list) { dispatch_async(dispatch_get_main_queue(),^{ self.contentRuleList=list; }); } + }]; + }]; + + // Use visibleFrame (excludes menu bar + Dock) so default placement is never + // behind system chrome. Clamp to 85% of available space on smaller screens. + NSRect vf=[NSScreen mainScreen].visibleFrame; + CGFloat defW=MIN(1280, floor(vf.size.width*0.9)); + CGFloat defH=MIN(800, floor(vf.size.height*0.9)); + NSRect contentFrame=NSMakeRect(0,0,defW,defH); // used for initWithContentRect: + BOOL useCenter=YES; + NSString *saved=[[NSUserDefaults standardUserDefaults] stringForKey:@"BBWindowFrame"]; + if (saved) { + NSRect r=NSRectFromString(saved); + // Validate saved frame: must have reasonable size AND fit on a visible screen + if (!NSIsEmptyRect(r)&&r.size.width>400&&r.size.height>300) { + // Ensure the top of the saved window is below the menu bar + CGFloat screenTop=vf.origin.y+vf.size.height; + if (r.origin.y+r.size.height <= screenTop+50) { // allow 50pt overshoot + contentFrame=r; useCenter=NO; + } + } + } + + self.window=[[NSWindow alloc]initWithContentRect:contentFrame + styleMask:(NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskResizable| + NSWindowStyleMaskMiniaturizable|NSWindowStyleMaskFullSizeContentView) + backing:NSBackingStoreBuffered defer:NO]; + self.window.title=@"BearBrowser"; + self.window.titlebarAppearsTransparent=YES; + self.window.titleVisibility=NSWindowTitleHidden; + self.window.minSize=NSMakeSize(640,480); + self.window.delegate=self; + if (useCenter) [self.window center]; else [self.window setFrame:contentFrame display:NO]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(windowWillClose:) + name:NSWindowWillCloseNotification object:self.window]; + + self.root=[[NSView alloc]initWithFrame:self.window.contentView.bounds]; + self.root.autoresizingMask=NSViewWidthSizable|NSViewHeightSizable; + self.window.contentView=self.root; + + CGFloat W=self.root.bounds.size.width, H=self.root.bounds.size.height; + + // Toolbar — NSVisualEffectView with Sidebar material (NOT Titlebar, so it does + // not register as a titlebar zone and does not intercept mouse events). + NSVisualEffectView *tbVE=[[NSVisualEffectView alloc]initWithFrame:NSMakeRect(0,H-kToolbarH,W,kToolbarH)]; + tbVE.material=NSVisualEffectMaterialSidebar; + tbVE.blendingMode=NSVisualEffectBlendingModeWithinWindow; + tbVE.state=NSVisualEffectStateActive; + tbVE.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + self.toolbarBg=tbVE; + NSBox *tbSep=[[NSBox alloc]initWithFrame:NSMakeRect(0,0,W,1)]; + tbSep.autoresizingMask=NSViewWidthSizable; tbSep.boxType=NSBoxSeparator; + [self.toolbarBg addSubview:tbSep]; + [self.root addSubview:self.toolbarBg]; + + CGFloat btnY=(kToolbarH-32)/2, x=80; + self.backButton =[self navBtn:@"chevron.left" tip:@"Back" sel:@selector(goBack:) x:x y:btnY]; x+=34; + self.forwardButton=[self navBtn:@"chevron.right" tip:@"Forward" sel:@selector(goForward:) x:x y:btnY]; x+=34; + self.reloadButton =[self navBtn:@"arrow.clockwise" tip:@"Reload" sel:@selector(reloadOrStop:) x:x y:btnY]; x+=40; + self.backButton.enabled=NO; self.forwardButton.enabled=NO; + for (NSButton *b in @[self.backButton,self.forwardButton,self.reloadButton]) + [self.toolbarBg addSubview:b]; + + // Security indicator + self.securityButton=[[NSButton alloc]initWithFrame:NSMakeRect(x,btnY+2,26,26)]; x+=28; + self.securityButton.bezelStyle=NSBezelStyleToolbar; self.securityButton.bordered=NO; + self.securityButton.target=self; self.securityButton.action=@selector(showSecurityInfo:); + [self updateSecurityIndicator:nil]; [self.toolbarBg addSubview:self.securityButton]; + + // Address bar + CGFloat rightR=48; + self.address=[[NSTextField alloc]initWithFrame:NSMakeRect(x,btnY+1,W-x-rightR-12,28)]; + self.address.autoresizingMask=NSViewWidthSizable; + self.address.bezelStyle=NSTextFieldRoundedBezel; + self.address.placeholderString=@"Search or enter address"; + self.address.font=[NSFont systemFontOfSize:13.5]; self.address.stringValue=@""; + self.address.delegate=self; [self.address.cell setWraps:NO]; [self.address.cell setScrollable:YES]; + [self.toolbarBg addSubview:self.address]; + + // Network monitor button + NSButton *netBtn=[self navBtn:@"network" tip:@"Network Monitor (⇧⌘M)" sel:@selector(openNetworkMonitor:) x:W-rightR-58 y:btnY]; + netBtn.autoresizingMask=NSViewMinXMargin; [self.toolbarBg addSubview:netBtn]; + // Read aloud button + NSButton *voiceBtn=[self navBtn:@"waveform" tip:@"Read Aloud (⌥⌘R)" sel:@selector(readAloud:) x:W-rightR-30 y:btnY]; + voiceBtn.autoresizingMask=NSViewMinXMargin; [self.toolbarBg addSubview:voiceBtn]; + // Bear panel button + NSButton *bearBtn=[self navBtn:@"ellipsis.circle" tip:@"BearBrowser Panel" sel:@selector(showBearPanel:) x:W-rightR+4 y:btnY]; + bearBtn.autoresizingMask=NSViewMinXMargin; [self.toolbarBg addSubview:bearBtn]; + + // Tab bar + self.tabBarView=[[BBTabBarView alloc]initWithFrame:NSMakeRect(0,H-kToolbarH-kTabBarH,W,kTabBarH) delegate:self]; + self.tabBarView.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + self.tabBarView.addTabButton.target=self; self.tabBarView.addTabButton.action=@selector(newTab:); + [self.root addSubview:self.tabBarView]; + + // Progress bar — real percentage + CGFloat chromH=kToolbarH+kTabBarH; + self.progressBar=[[NSProgressIndicator alloc]initWithFrame:NSMakeRect(0,H-chromH-2,W,2)]; + self.progressBar.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + self.progressBar.style=NSProgressIndicatorStyleBar; + self.progressBar.indeterminate=NO; self.progressBar.minValue=0; self.progressBar.maxValue=1; + self.progressBar.controlSize=NSControlSizeSmall; self.progressBar.hidden=YES; + [self.root addSubview:self.progressBar]; + + // Find bar + self.findBar=[[BBFindBar alloc]initWithFrame:NSMakeRect(0,0,W,kFindBarH)]; + self.findBar.autoresizingMask=NSViewWidthSizable; self.findBar.hidden=YES; + self.findBar.closeButton.target=self; self.findBar.closeButton.action=@selector(closeFind:); + self.findBar.prevButton.target=self; self.findBar.prevButton.action=@selector(findPrev:); + self.findBar.nextButton.target=self; self.findBar.nextButton.action=@selector(findNext:); + self.findBar.queryField.delegate=self; + [self.root addSubview:self.findBar]; + + // Bookmarks bar (hidden by default, Cmd+Shift+B toggles) + self.bookmarksBar=[[NSView alloc]initWithFrame:NSMakeRect(0,H-kToolbarH-kTabBarH-kBMBarH,W,kBMBarH)]; + self.bookmarksBar.autoresizingMask=NSViewWidthSizable|NSViewMinYMargin; + self.bookmarksBar.wantsLayer=YES; self.bookmarksBar.layer.backgroundColor=[NSColor windowBackgroundColor].CGColor; + self.bookmarksBar.hidden=YES; + [self.root addSubview:self.bookmarksBar]; - NSRect frame = NSMakeRect(0, 0, 1440, 820); - self.window = [[NSWindow alloc] initWithContentRect:frame - styleMask:(NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskResizable | NSWindowStyleMaskMiniaturizable) - backing:NSBackingStoreBuffered - defer:NO]; - [self.window setTitle:@"BearBrowser"]; - [self.window center]; - - NSView *root = [[NSView alloc] initWithFrame:frame]; - [root setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; - [self.window setContentView:root]; - - CGFloat toolbarH = 48.0; - NSView *toolbar = [[NSView alloc] initWithFrame:NSMakeRect(0, frame.size.height - toolbarH, frame.size.width, toolbarH)]; - [toolbar setAutoresizingMask:NSViewWidthSizable | NSViewMinYMargin]; - [root addSubview:toolbar]; - - NSButton *back = [NSButton buttonWithTitle:@"‹" target:self action:@selector(goBack:)]; - NSButton *fwd = [NSButton buttonWithTitle:@"›" target:self action:@selector(goForward:)]; - NSButton *reload = [NSButton buttonWithTitle:@"↻" target:self action:@selector(reload:)]; - NSButton *summary = [NSButton buttonWithTitle:@"Summarize Page" target:self action:@selector(summarizePage:)]; - NSButton *share = [NSButton buttonWithTitle:@"Propose Share" target:self action:@selector(proposePageShare:)]; - NSButton *memory = [NSButton buttonWithTitle:@"Memory Candidate" target:self action:@selector(createMemoryCandidate:)]; - NSButton *resolve = [NSButton buttonWithTitle:@"Resolve Held" target:self action:@selector(resolveHeld:)]; - NSButton *sidecar = [NSButton buttonWithTitle:@"Sidecar Status" target:self action:@selector(openSidecarStatus:)]; - - CGFloat x = 12.0; - for (NSButton *button in @[back, fwd, reload]) { - [button setFrame:NSMakeRect(x, 9, 38, 30)]; - [button setBezelStyle:NSBezelStyleRounded]; - [toolbar addSubview:button]; - x += 44.0; - } - - CGFloat right = frame.size.width - 810; - [summary setFrame:NSMakeRect(right, 9, 132, 30)]; - [share setFrame:NSMakeRect(right + 140, 9, 124, 30)]; - [memory setFrame:NSMakeRect(right + 272, 9, 150, 30)]; - [resolve setFrame:NSMakeRect(right + 430, 9, 118, 30)]; - [sidecar setFrame:NSMakeRect(right + 556, 9, 132, 30)]; - for (NSButton *button in @[summary, share, memory, resolve, sidecar]) { - [button setAutoresizingMask:NSViewMinXMargin]; - [button setBezelStyle:NSBezelStyleRounded]; - [toolbar addSubview:button]; - } - - self.address = [[NSTextField alloc] initWithFrame:NSMakeRect(x + 6, 9, frame.size.width - x - 826, 30)]; - [self.address setAutoresizingMask:NSViewWidthSizable]; - [self.address setDelegate:self]; - [self.address setStringValue:@"bearbrowser://start"]; - [toolbar addSubview:self.address]; - - WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init]; - self.webView = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, frame.size.width, frame.size.height - toolbarH) configuration:config]; - [self.webView setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; - [self.webView setNavigationDelegate:self]; - [root addSubview:self.webView]; - - NSURL *landing = [[NSBundle mainBundle] URLForResource:@"BearBrowser-start" withExtension:@"html"]; - if (landing) { - [self.webView loadFileURL:landing allowingReadAccessToURL:[landing URLByDeletingLastPathComponent]]; - BBLog([NSString stringWithFormat:@"loaded %@", landing.path]); + // Download panel (right edge, hidden by default) + self.downloadPanel=[[BBDownloadPanel alloc]initWithFrame:NSMakeRect(W-kDLPanelW,0,kDLPanelW,H-kToolbarH-kTabBarH)]; + self.downloadPanel.autoresizingMask=NSViewMinXMargin|NSViewHeightSizable; + self.downloadPanel.hidden=YES; [self.root addSubview:self.downloadPanel]; + + // Address autocomplete dropdown + self.addressDropdown=[[BBAddressDropdown alloc]init]; + self.addressDropdown.delegate=self; + + self.dnsBlockCache=[[NSCache alloc]init]; + self.decoyViews=[NSMutableSet set]; + self.dnsBlockCache.countLimit=2000; + + self.tabs=[NSMutableArray array]; self.activeTabIndex=0; + // Session restore — reopen tabs from last session + NSArray *savedURLs=[[NSUserDefaults standardUserDefaults] arrayForKey:@"BBSessionURLs"]; + BOOL restored=NO; + if (savedURLs.count) { + for (NSString *u in savedURLs) { + if (!u.length) continue; + [self addTabPrivate:NO]; + [self.webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:u]]]; + restored=YES; + } + } + if (!restored) [self addTabPrivate:NO]; + [self.window makeKeyAndOrderFront:nil]; [NSApp activateIgnoringOtherApps:YES]; + BBLog([NSString stringWithFormat:@"window frame=%@ root=%@ toolbar=%@", + NSStringFromRect(self.window.frame), NSStringFromRect(self.root.bounds), NSStringFromRect(self.toolbarBg.frame)]); + // Focus the address bar immediately on launch — user can type a URL right away. + dispatch_async(dispatch_get_main_queue(),^{ + [self.window makeFirstResponder:self.address]; + [self.address selectText:nil]; + }); + // Dismiss the address dropdown when the user clicks outside it or the address field. + [NSEvent addLocalMonitorForEventsMatchingMask: + NSEventMaskLeftMouseDown|NSEventMaskRightMouseDown|NSEventMaskKeyDown + handler:^NSEvent*(NSEvent *e){ + self.lastUserGestureTime=[NSDate timeIntervalSinceReferenceDate]; + if (e.type==NSEventTypeLeftMouseDown && e.window==self.window) { + NSView *overlay=self.addressDropdown.overlay; + NSPoint pt=[self.root convertPoint:e.locationInWindow fromView:nil]; + NSView *hit=[self.root hitTest:pt]; + BOOL inOverlay = overlay && !overlay.hidden && (hit==overlay || [hit isDescendantOf:overlay]); + BOOL inAddress = (hit==self.address || [hit isDescendantOf:self.address] || + hit==[self.address currentEditor] || [hit isDescendantOf:[self.address currentEditor]]); + if (!inOverlay && !inAddress) [self.addressDropdown hide]; + } + return e; + }]; + [self installContextMenuMonitor]; +} + +// Returns YES for URLs that should show as blank in the address bar (start page, new-tab). +- (BOOL)isInternalURL:(NSString *)url { + if (!url.length) return YES; + if ([url hasPrefix:@"bearbrowser://"]) return YES; + // Bundled start page + NSString *startPath=[[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"BearBrowser-start.html"]; + NSString *startURL=[[NSURL fileURLWithPath:startPath] absoluteString]; + return [url isEqualToString:startURL]; +} + +// ── Nav button factory ──────────────────────────────────────────────────────── +- (NSButton *)navBtn:(NSString *)sym tip:(NSString *)tip sel:(SEL)sel x:(CGFloat)x y:(CGFloat)y { + NSButton *b=[[NSButton alloc]initWithFrame:NSMakeRect(x,y,32,32)]; + NSImage *img=[NSImage imageWithSystemSymbolName:sym accessibilityDescription:tip]; + img=[img imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:14 weight:NSFontWeightMedium]]; + [img setTemplate:YES]; b.image=img; b.imagePosition=NSImageOnly; + b.bezelStyle=NSBezelStyleToolbar; b.bordered=NO; b.target=self; b.action=sel; b.toolTip=tip; + return b; +} + +// ── Security indicator ──────────────────────────────────────────────────────── +- (void)updateSecurityIndicator:(NSURL *)url { + NSString *sym=@"globe"; NSColor *tint=[NSColor tertiaryLabelColor]; + NSString *tip=@""; + if (url) { + if ([url.scheme isEqualToString:@"https"]) { + sym=@"lock.fill"; tint=[NSColor systemGreenColor]; tip=@"Secure connection (HTTPS)"; + } else if ([url.scheme isEqualToString:@"http"]) { + sym=@"exclamationmark.triangle.fill"; tint=[NSColor systemOrangeColor]; tip=@"Not secure — connection is not encrypted"; + } else if ([url.scheme isEqualToString:@"file"]) { + sym=@"doc.fill"; tint=[NSColor secondaryLabelColor]; tip=@"Local file"; + } + } + NSImage *img=[NSImage imageWithSystemSymbolName:sym accessibilityDescription:tip]; + img=[img imageWithSymbolConfiguration:[NSImageSymbolConfiguration configurationWithPointSize:13 weight:NSFontWeightMedium]]; + // Apply tint via symbol color config + NSImageSymbolConfiguration *colorCfg=[NSImageSymbolConfiguration configurationWithHierarchicalColor:tint]; + img=[img imageWithSymbolConfiguration:colorCfg]; + self.securityButton.image=img; self.securityButton.imagePosition=NSImageOnly; + self.securityButton.toolTip=tip.length?tip:@"Security info"; +} +- (void)showSecurityInfo:(id)s { + NSURL *url=self.webView.URL; + NSString *host=url.host?:@""; + BOOL isHTTPS=[url.scheme isEqualToString:@"https"]; + NSMutableString *info=[NSMutableString string]; + + if (!isHTTPS) { + [info appendString:url?@"⚠️ Connection is NOT encrypted (HTTP)\nData sent to this site can be intercepted.\n":@"Internal page"]; } else { - [self.webView loadHTMLString:@"

BearBrowser

Landing page missing.

" baseURL:nil]; - BBLog(@"landing page missing"); - } - - [self.window makeKeyAndOrderFront:nil]; - [NSApp activateIgnoringOtherApps:YES]; -} - -- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender { return YES; } -- (void)goBack:(id)sender { if (self.webView.canGoBack) [self.webView goBack]; } -- (void)goForward:(id)sender { if (self.webView.canGoForward) [self.webView goForward]; } -- (void)reload:(id)sender { [self.webView reload]; } - -- (NSString *)currentURLString { - return self.webView.URL.absoluteString ?: @"bearbrowser://start"; -} - -- (NSString *)runCommandAndCaptureOutput:(NSString *)command status:(int *)status { - NSTask *task = [[NSTask alloc] init]; - NSPipe *pipe = [NSPipe pipe]; - task.launchPath = @"/bin/bash"; - task.arguments = @[@"-lc", [NSString stringWithFormat:@"PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin; %@", command]]; - task.standardOutput = pipe; - task.standardError = pipe; - [task launch]; - [task waitUntilExit]; - if (status) { *status = task.terminationStatus; } - NSData *data = [[pipe fileHandleForReading] readDataToEndOfFile]; - NSString *output = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @""; - return [output stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; -} - -- (void)runCommand:(NSString *)command successMessage:(NSString *)successMessage { - int status = 0; - NSString *output = [self runCommandAndCaptureOutput:command status:&status]; - BBLog([NSString stringWithFormat:@"command exit=%d command=%@ output=%@", status, command, output]); - [self openSidecarStatus:nil]; - NSAlert *alert = [[NSAlert alloc] init]; - alert.messageText = status == 0 ? successMessage : @"BearBrowser command failed"; - alert.informativeText = status == 0 ? @"Interactive Sidecar has been refreshed." : @"Check ~/Library/Logs/BearBrowser/launcher.log and the local governance logs."; - [alert addButtonWithTitle:@"OK"]; - [alert beginSheetModalForWindow:self.window completionHandler:nil]; -} - -- (void)summarizePage:(id)sender { - NSString *js = @"(document.body && document.body.innerText ? document.body.innerText : (document.documentElement && document.documentElement.innerText ? document.documentElement.innerText : '')).slice(0, 12000)"; - [self.webView evaluateJavaScript:js completionHandler:^(id result, NSError *error) { - if (error) { - BBLog([NSString stringWithFormat:@"summary extraction error=%@", error.localizedDescription]); - NSAlert *alert = [[NSAlert alloc] init]; - alert.messageText = @"Could not summarize page"; - alert.informativeText = @"BearBrowser could not read visible page text from the current WebKit page."; - [alert addButtonWithTitle:@"OK"]; - [alert beginSheetModalForWindow:self.window completionHandler:nil]; - return; + [info appendString:@"🔒 Connection is encrypted (TLS)\n\n"]; + // Walk cert chain from stored trust + SecTrustRef trust=self.currentTrust; + if (trust) { + CFArrayRef chain=SecTrustCopyCertificateChain(trust); + CFIndex count=chain?CFArrayGetCount(chain):0; + for (CFIndex i=0;i4,configurable:false}," + @" deviceMemory:{get:()=>4,configurable:false}," + @" languages:{get:()=>Object.freeze(['en-US','en']),configurable:false}" + @"});}catch(e){}" + // Screen colour depth normalisation + @"try{Object.defineProperty(screen,'colorDepth',{get:()=>24,configurable:false});}catch(e){}" + // Battery API removed (fingerprinting vector) + @"if(navigator.getBattery)try{delete navigator.__proto__.getBattery;}catch(e){navigator.getBattery=undefined;}" + // WebRTC IP leak — strip ICE servers so only relay traffic is visible + @"const _RPC=window.RTCPeerConnection||window.webkitRTCPeerConnection;" + @"if(_RPC){window.RTCPeerConnection=function(cfg,con){" + @" return new _RPC(cfg?{...cfg,iceServers:[]}:null,con);};" + @" window.RTCPeerConnection.prototype=_RPC.prototype;}" + // eval honeypot — log when eval is called with suspicious patterns + @"const _eval=window.eval;" + @"window.eval=function(code){" + @" if(typeof code==='string'&&(" + @" code.includes('document.cookie')||code.includes('localStorage')||" + @" code.includes('sessionStorage')||code.includes('XMLHttpRequest')||" + @" code.length>2000)){" + @" try{if(window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.honeypot)" + @" window.webkit.messageHandlers.honeypot.postMessage({trap:'eval',url:location.href,len:code.length,time:Date.now()});}" + @" catch(e){}" + @" }" + @" return _eval.call(this,code);};" + // postMessage origin guard — warn on wildcard target from untrusted frames + @"const _pM=window.postMessage.bind(window);" + @"window.postMessage=function(data,origin,transfer){" + @" if(origin==='*'&&window.top!==window)" + @" try{if(window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.honeypot)" + @" window.webkit.messageHandlers.honeypot.postMessage({trap:'wildcard_postmessage',url:location.href,time:Date.now()});}" + @" catch(e){}" + @" return _pM(data,origin,transfer);};" + // iFrame sandbox hardening — add sandbox to cross-origin frames at load + @"document.addEventListener('DOMContentLoaded',function(){" + @" document.querySelectorAll('iframe').forEach(function(f){" + @" try{" + @" var fsrc=new URL(f.src||'',location.href);" + @" if(fsrc.origin!==location.origin&&!f.hasAttribute('sandbox')){" + @" f.setAttribute('sandbox','allow-scripts allow-same-origin allow-forms allow-popups');" + @" }" + @" }catch(e){}" + @" });" + @"},false);" + @"})();"; + WKUserScript *shieldScript=[[WKUserScript alloc] + initWithSource:shield + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:NO]; + [config.userContentController addUserScript:shieldScript]; + // ── Honeypot canary — alerts when scrapers/exploits probe well-known targets ─ + NSString *canary= + @"(function(){'use strict';" + @"function trap(name,fake){" + @" Object.defineProperty(window,name,{get:function(){" + @" try{if(window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.honeypot)" + @" window.webkit.messageHandlers.honeypot.postMessage({trap:name,url:location.href,time:Date.now()});}" + @" catch(e){}" + @" return fake;}," + @" configurable:false,enumerable:false});}" + // Fake credential properties — only automated tools probe these + @"trap('__bb_admin_token','eyJhbGciOiJIUzI1NiJ9.HONEYPOT.TRAP');" + @"trap('__bb_session_key','sk-bear-0000000000000000-TRAP');" + @"trap('__bb_api_base','https://api.bearbrowser.internal/v1');" + @"trap('__bb_config',{debug:false,admin:false,env:'production'});" + // Watch for document.cookie bulk harvest attempts + @"const _cookieDesc=Object.getOwnPropertyDescriptor(Document.prototype,'cookie')||" + @" Object.getOwnPropertyDescriptor(HTMLDocument.prototype,'cookie');" + @"if(_cookieDesc&&_cookieDesc.get){" + @" let _hc=0;" + @" const _origGet=_cookieDesc.get;" + @" Object.defineProperty(document,'cookie',{get:function(){" + @" _hc++;if(_hc>20&&_hc%10===0)" + @" try{if(window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.honeypot)" + @" window.webkit.messageHandlers.honeypot.postMessage({trap:'cookie_harvest',url:location.href,count:_hc,time:Date.now()});}" + @" catch(e){}" + @" return _origGet.call(document);}," + @" set:_cookieDesc.set,configurable:true});" + @"}" + @"})();"; + WKUserScript *canaryScript=[[WKUserScript alloc] + initWithSource:canary + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:YES]; + [config.userContentController addUserScript:canaryScript]; + // Network monitor — wraps fetch/XHR and uses PerformanceObserver to report all resource loads + NSString *netmonJS= + @"(function(){'use strict';" + @"function _rep(url,type){" + @" try{" + @" var h=new URL(url,location.href).hostname||'';" + @" window.webkit.messageHandlers.netmon.postMessage({domain:h,page:location.href,type:type});" + @" }catch(e){}" + @"}" + @"var _f=window.fetch;" + @"window.fetch=function(input,init){" + @" _rep(typeof input==='string'?input:(input&&input.url)||'','fetch');" + @" return _f.apply(this,arguments);" + @"};" + @"var _xo=XMLHttpRequest.prototype.open;" + @"XMLHttpRequest.prototype.open=function(m,url){" + @" _rep(String(url),'xhr');" + @" return _xo.apply(this,arguments);" + @"};" + @"if(typeof PerformanceObserver!=='undefined'){" + @" try{" + @" var po=new PerformanceObserver(function(list){" + @" list.getEntries().forEach(function(e){" + @" if(e.name&&e.initiatorType)_rep(e.name,e.initiatorType);" + @" });" + @" });" + @" po.observe({entryTypes:['resource']});" + @" }catch(ex){}" + @"}" + @"})();"; + WKUserScript *netmonScript=[[WKUserScript alloc] + initWithSource:netmonJS + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:NO]; + [config.userContentController addUserScript:netmonScript]; + + // ── Security monitor — JS-side injection detection ──────────────────────── + NSString *secmonJS= + @"(function(){'use strict';" + @"function _sm(type,detail){" + @" try{if(window.webkit&&window.webkit.messageHandlers&&window.webkit.messageHandlers.secmon)" + @" window.webkit.messageHandlers.secmon.postMessage({type:type,page:location.href,detail:String(detail).slice(0,1000)});}" + @" catch(e){}}" + // eval monitoring — already hooked in shield for honeypot; extend for secmon + @"const _ev=window.eval;" + @"window.eval=function(code){" + @" _sm('eval',typeof code==='string'?code.slice(0,800):typeof code);" + @" return _ev.call(this,code);};" + // Function constructor + @"const _Fn=window.Function;" + @"window.Function=function(){var a=Array.from(arguments);" + @" _sm('Function',a.join('|').slice(0,800));" + @" return _Fn.apply(this,a);};" + @"window.Function.prototype=_Fn.prototype;" + // MutationObserver — detect dynamic