| … | … | @@ -35,6 +35,9 @@ checksum-verified, pinned on HiveRelay, hot-swappable, and usable offline. |
| 35 | 35 | |
| 36 | 36 | - A list checksum mismatch, malformed/oversized drive, missing capability, or |
| 37 | 37 | silent plugin capability increase fails closed before any contribution runs. |
| 38 | - Plugin install/re-consent grants are bound to the exact reviewed manifest and | |
| 39 | asset snapshot; community catalogues cannot mint the browser's curated mark, | |
| 40 | and third-party CSS cannot break out of its browser-owned style element. | |
| 38 | 41 | - Blocking remains browser-owned: denied requests never reach peers, relays, or |
| 39 | 42 | clearnet, and cached lists keep working offline. |
| 40 | 43 |
| … | … | @@ -10,15 +10,15 @@ A local-first peer-to-peer browser, app store, search engine, naming layer, Nost |
| 10 | 10 | |
| 11 | 11 | ## Install it |
| 12 | 12 | |
| 13 | Primary desktop distribution is now native GitHub release packages. The current | |
| 14 | `v0.6.1` release has attached macOS, Windows, and Linux assets with SHA-256 | |
| 15 | sidecars and platform manifests. Download from the | |
| 16 | [`v0.6.1` release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.6.1), | |
| 13 | Primary desktop distribution is now native GitHub release packages. The | |
| 14 | `v0.7.0` release targets macOS, Windows, and Linux assets with SHA-256 sidecars | |
| 15 | and platform manifests. Download from the | |
| 16 | [`v0.7.0` release](https://github.com/bigdestiny2/pearbrowser-desktop/releases/tag/v0.7.0), | |
| 17 | 17 | follow the [native install guide](./docs/INSTALL_NATIVE_PACKAGES.md), or resolve |
| 18 | 18 | the recommended asset for your machine from a source checkout: |
| 19 | 19 | |
| 20 | 20 | ```sh |
| 21 | npm run resolve:native-release -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop | |
| 21 | npm run resolve:native-release -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop | |
| 22 | 22 | ``` |
| 23 | 23 | |
| 24 | 24 | The current package targets match the `cmake-pear` appling toolchain: |
| … | … | @@ -224,29 +224,29 @@ node scripts/verify-live-catalog.js --expect-app peercord --expect-app peerit -- |
| 224 | 224 | |
| 225 | 225 | ## Distribution |
| 226 | 226 | |
| 227 | The `appling/` directory contains the multi-architecture native shell — Bare + CMake builds for macOS / Windows / Linux. GitHub release assets are produced by `.github/workflows/desktop-native-release.yml`, which builds the appling on hosted macOS, Windows, and Linux runners, collects the native artifacts, writes SHA-256 sidecars, and attaches them to the matching release tag. Run the workflow manually with tag `v0.6.1` and `source_ref` set to the release commit to produce or refresh the attached release assets. | |
| 227 | The `appling/` directory contains the multi-architecture native shell — Bare + CMake builds for macOS / Windows / Linux. GitHub release assets are produced by `.github/workflows/desktop-native-release.yml`, which builds the appling on hosted macOS, Windows, and Linux runners, collects the native artifacts, writes SHA-256 sidecars, and attaches them to the matching release tag. Run the workflow manually with tag `v0.7.0` and `source_ref` set to the release commit to produce or refresh the attached release assets. | |
| 228 | 228 | |
| 229 | 229 | Current generated artifacts are `.app.zip` on macOS, `.msix` on Windows, and `.AppImage` on Linux. The workflow uses `npm ci --prefix appling`, so update `appling/package-lock.json` deliberately when the native wrapper toolchain changes. |
| 230 | 230 | |
| 231 | 231 | ```sh |
| 232 | npm run check:appling-release -- --tag v0.6.1 | |
| 232 | npm run check:appling-release -- --tag v0.7.0 | |
| 233 | 233 | npm run check:linux-appimage-metadata |
| 234 | npm run resolve:native-release -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop | |
| 235 | npm run -s generate:native-signing-secret-plan -- --repo bigdestiny2/pearbrowser-desktop --tag v0.6.1 --source-ref <release-commit> | |
| 236 | npm run -s generate:native-install-snippet -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop | |
| 237 | npm run -s generate:native-install-guide -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop | |
| 238 | npm run -s generate:native-install-smoke-plan -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> | |
| 239 | npm run generate:package-manager-manifests -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop --trust-mode package-proof | |
| 234 | npm run resolve:native-release -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop | |
| 235 | npm run -s generate:native-signing-secret-plan -- --repo bigdestiny2/pearbrowser-desktop --tag v0.7.0 --source-ref <release-commit> | |
| 236 | npm run -s generate:native-install-snippet -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop | |
| 237 | npm run -s generate:native-install-guide -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop | |
| 238 | npm run -s generate:native-install-smoke-plan -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> | |
| 239 | npm run generate:package-manager-manifests -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --trust-mode package-proof | |
| 240 | 240 | npm run check:native-signing -- --require-public-trust --secret-source github --repo bigdestiny2/pearbrowser-desktop |
| 241 | npm run check:public-trust-readiness -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github | |
| 242 | npm run -s generate:public-trust-operator-report -- --tag v0.6.1 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github | |
| 241 | npm run check:public-trust-readiness -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github | |
| 242 | npm run -s generate:public-trust-operator-report -- --tag v0.7.0 --repo bigdestiny2/pearbrowser-desktop --source-ref <release-commit> --signing-secret-source github | |
| 243 | 243 | npm run -s generate:release-evidence-handoff |
| 244 | 244 | cd appling |
| 245 | 245 | npm ci |
| 246 | 246 | npm run generate |
| 247 | 247 | npm run build |
| 248 | 248 | cd .. |
| 249 | npm run package:appling -- --tag v0.6.1 | |
| 249 | npm run package:appling -- --tag v0.7.0 | |
| 250 | 250 | ``` |
| 251 | 251 | |
| 252 | 252 | Code signing is per-platform: |
| … | … | @@ -209,8 +209,8 @@ const CMD_PLUGIN_REGISTER = 237 // { id, manifest, contribution?, enabled? } |
| 209 | 209 | const CMD_SHIELD_SUBSCRIBE_LIST = 239 // { driveKey } → subscribe to a filter-list drive |
| 210 | 210 | const CMD_SHIELD_UNSUBSCRIBE_LIST = 240 // { driveKey } |
| 211 | 211 | const CMD_SHIELD_REFRESH_LISTS = 241 // { driveKey?, force? } → refresh one or all subscriptions |
| 212 | const CMD_PLUGIN_INSTALL_DRIVE = 242 // { driveKey, granted? } → install a plugin from its drive | |
| 213 | const CMD_PLUGIN_UPDATE_DRIVE = 243 // { driveKey, acceptEscalation? } | |
| 212 | const CMD_PLUGIN_INSTALL_DRIVE = 242 // preview {driveKey}; accept {driveKey, granted, reviewedFingerprint} | |
| 213 | const CMD_PLUGIN_UPDATE_DRIVE = 243 // update/preview escalation; accept with grant + reviewedFingerprint | |
| 214 | 214 | const CMD_PLUGIN_UNINSTALL = 244 // { driveKey } |
| 215 | 215 | const CMD_PLUGIN_CATALOG = 245 // → { entries, sources } (builtin seed + subscribed catalogue drives) |
| 216 | 216 | const CMD_PLUGIN_CATALOG_LOAD_DRIVE = 246 // { driveKey } → subscribe to a catalogue drive (/plugins.json) |
| … | … | @@ -731,7 +731,9 @@ function parseFilterList (text) { |
| 731 | 731 | if (!/[\s#]/.test(rawHostPart)) { |
| 732 | 732 | const hostPart = normalizeHost(rawHostPart) |
| 733 | 733 | const selector = line.slice(cosmeticIndex + 2).trim() |
| 734 | if (selector && !selector.includes('{') && !selector.includes('}')) { | |
| 734 | // Selectors are embedded into a browser-owned <style> block. Reject | |
| 735 | // markup delimiters here and also escape style text at the proxy sink. | |
| 736 | if (selector && !/[{}<>]/.test(selector)) { | |
| 735 | 737 | cosmetic.push({ host: hostPart || null, selector }) |
| 736 | 738 | } |
| 737 | 739 | continue |
| … | … | @@ -201,6 +201,15 @@ function escapeHtml (str) { |
| 201 | 201 | .replace(/`/g, '`') |
| 202 | 202 | } |
| 203 | 203 | |
| 204 | // A <style> element is an HTML raw-text container: the HTML parser will end it | |
| 205 | // on a literal "</style" even when those bytes came from otherwise-valid CSS. | |
| 206 | // Escape every less-than sign as a CSS code point before embedding third-party | |
| 207 | // filter/plugin CSS so a style-only capability can never become script markup. | |
| 208 | function escapeStyleText (css) { | |
| 209 | if (typeof css !== 'string') return '' | |
| 210 | return css.replace(/</g, '\\3c ') | |
| 211 | } | |
| 212 | ||
| 204 | 213 | class HyperProxy { |
| 205 | 214 | constructor (getDrive, onError, relayClient, opts = {}) { |
| 206 | 215 | this._getDrive = getDrive // async (keyHex) => Hyperdrive |
| … | … | @@ -543,8 +552,8 @@ class HyperProxy { |
| 543 | 552 | (includeAnongpt ? this._anongptShim : '') + |
| 544 | 553 | scriptletTags.join('') + |
| 545 | 554 | pluginScriptTags.join('') + |
| 546 | (shieldCss ? `<style data-pear-shield>${shieldCss}</style>` : '') + | |
| 547 | (pluginCss ? `<style data-pear-plugin-style>${pluginCss}</style>` : '') | |
| 555 | (shieldCss ? `<style data-pear-shield>${escapeStyleText(shieldCss)}</style>` : '') + | |
| 556 | (pluginCss ? `<style data-pear-plugin-style>${escapeStyleText(pluginCss)}</style>` : '') | |
| 548 | 557 | let injected = html.includes('<head>') |
| 549 | 558 | ? html.replace('<head>', `<head>${headInjection}`) |
| 550 | 559 | : html.replace(/<html>/i, `<html><head>${headInjection}</head>`) |
| … | … | @@ -1222,5 +1231,6 @@ li{padding:8px 0;border-bottom:1px solid #333}a{color:#4dabf7;text-decoration:no |
| 1222 | 1231 | module.exports = { |
| 1223 | 1232 | HyperProxy, |
| 1224 | 1233 | getUserFriendlyError, |
| 1225 | USER_FRIENDLY_ERRORS | |
| 1234 | USER_FRIENDLY_ERRORS, | |
| 1235 | escapeStyleText | |
| 1226 | 1236 | } |
| … | … | @@ -12,6 +12,7 @@ const Hyperdrive = require('hyperdrive') |
| 12 | 12 | const b4a = require('b4a') |
| 13 | 13 | const z32 = require('z32') |
| 14 | 14 | const fs = require('bare-fs') |
| 15 | const crypto = require('bare-crypto') | |
| 15 | 16 | |
| 16 | 17 | // Normalize a Hyperdrive key from either 64-char hex or |
| 17 | 18 | // 52-char z-base-32 (Vinjari-style) into lowercase hex. |
| … | … | @@ -501,7 +502,8 @@ rpc.handle(C.CMD_PLUGIN_INSTALL_DRIVE, async (data = {}) => { |
| 501 | 502 | await whenReady() |
| 502 | 503 | if (!pluginDriveLoader) throw new Error('Plugin drive loader not available') |
| 503 | 504 | const result = await pluginDriveLoader.installFromDrive(data.driveKey, { |
| 504 | grantedCapabilities: data.granted | |
| 505 | grantedCapabilities: data.granted, | |
| 506 | reviewedFingerprint: data.reviewedFingerprint | |
| 505 | 507 | }) |
| 506 | 508 | await persistShieldState() |
| 507 | 509 | return result |
| … | … | @@ -511,7 +513,8 @@ rpc.handle(C.CMD_PLUGIN_UPDATE_DRIVE, async (data = {}) => { |
| 511 | 513 | await whenReady() |
| 512 | 514 | if (!pluginDriveLoader) throw new Error('Plugin drive loader not available') |
| 513 | 515 | const result = await pluginDriveLoader.updateFromDrive(data.driveKey, { |
| 514 | acceptEscalation: !!data.acceptEscalation | |
| 516 | grantedCapabilities: data.granted, | |
| 517 | reviewedFingerprint: data.reviewedFingerprint | |
| 515 | 518 | }) |
| 516 | 519 | await persistShieldState() |
| 517 | 520 | return result |
| … | … | @@ -3174,10 +3177,20 @@ async function boot () { |
| 3174 | 3177 | // P2P distribution (Phase 2/3 gates). Rule text and plugin payloads were |
| 3175 | 3178 | // already rehydrated above, so both work fully offline; these two own |
| 3176 | 3179 | // the drive-sourced metadata and the hot-swap lifecycle. |
| 3177 | const sha256Hex = (buf) => require('crypto').createHash('sha256').update(buf).digest('hex') | |
| 3180 | const sha256Hex = (buf) => crypto.createHash('sha256').update(buf).digest('hex') | |
| 3181 | const refreshDistributionDrive = async (keyHex) => { | |
| 3182 | const drive = await getDriveForProxy(keyHex) | |
| 3183 | if (!drive) return | |
| 3184 | const before = drive.version | |
| 3185 | await updateDriveBestEffort(drive, 8000) | |
| 3186 | if (drive.version !== before && proxy && typeof proxy.invalidateCache === 'function') { | |
| 3187 | proxy.invalidateCache(keyHex) | |
| 3188 | } | |
| 3189 | } | |
| 3178 | 3190 | shieldListSync = new ShieldListSync({ |
| 3179 | 3191 | shield: contentShield, |
| 3180 | 3192 | fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path), |
| 3193 | refreshDrive: refreshDistributionDrive, | |
| 3181 | 3194 | sha256Hex, |
| 3182 | 3195 | persistMeta: async (meta) => { |
| 3183 | 3196 | if (userData) await userData.setSettings({ contentShieldListSync: meta }) |
| … | … | @@ -3189,6 +3202,8 @@ async function boot () { |
| 3189 | 3202 | pluginDriveLoader = new PluginDriveLoader({ |
| 3190 | 3203 | registry: pearPlugins, |
| 3191 | 3204 | fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path), |
| 3205 | refreshDrive: refreshDistributionDrive, | |
| 3206 | sha256Hex, | |
| 3192 | 3207 | persistInstall: async (id, payload) => { |
| 3193 | 3208 | if (payload === null) { |
| 3194 | 3209 | delete persistShieldState._pluginPayloads[id] |
| … | … | @@ -3210,7 +3225,8 @@ async function boot () { |
| 3210 | 3225 | pluginDriveLoader.restore(shieldSettings.contentShieldPluginInstalls) |
| 3211 | 3226 | } |
| 3212 | 3227 | pluginCatalog = new PluginCatalog({ |
| 3213 | fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path) | |
| 3228 | fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path), | |
| 3229 | refreshDrive: refreshDistributionDrive | |
| 3214 | 3230 | }) |
| 3215 | 3231 | if (shieldSettings && shieldSettings.contentShieldPluginCatalog) { |
| 3216 | 3232 | pluginCatalog.restore(shieldSettings.contentShieldPluginCatalog) |
| … | … | @@ -93,8 +93,9 @@ class PluginCatalog { |
| 93 | 93 | } |
| 94 | 94 | this._fetch = opts.fetchDriveFile |
| 95 | 95 | this._builtin = (Array.isArray(opts.builtin) ? opts.builtin : BUILTIN_PLUGIN_CATALOG) |
| 96 | .map(entry => validateCatalogEntry(entry)) | |
| 96 | .map(entry => validateCatalogEntry(entry, { trusted: true })) | |
| 97 | 97 | .filter(Boolean) |
| 98 | this._refreshDrive = typeof opts.refreshDrive === 'function' ? opts.refreshDrive : async () => {} | |
| 98 | 99 | this._sources = new Map() // driveKeyHex -> { name, entries, loadedAt } |
| 99 | 100 | this._now = typeof opts.now === 'function' ? opts.now : Date.now |
| 100 | 101 | } |
| … | … | @@ -130,6 +131,7 @@ class PluginCatalog { |
| 130 | 131 | const key = normalizeDriveKey(driveKey) |
| 131 | 132 | if (!key) throw new PluginCatalogError('invalid-drive-key', 'A 64-hex catalogue drive key is required') |
| 132 | 133 | |
| 134 | await this._refreshDrive(key) | |
| 133 | 135 | const fetched = await this._fetch(key, '/plugins.json') |
| 134 | 136 | const content = fetched && fetched.content ? fetched.content : null |
| 135 | 137 | if (!content || content.length === 0) { |
| … | … | @@ -225,7 +227,9 @@ function validateCatalogEntry (entry, opts = {}) { |
| 225 | 227 | description: cleanString(entry.description, 500), |
| 226 | 228 | author: cleanString(entry.author, 120), |
| 227 | 229 | capabilities, |
| 228 | verified: entry.verified === true, | |
| 230 | // Only browser-shipped entries may carry the curated trust mark. Drive | |
| 231 | // catalogues are untrusted discovery metadata, including after restore. | |
| 232 | verified: opts.trusted === true && entry.verified === true, | |
| 229 | 233 | ...(typeof entry.unpublished === 'string' && entry.unpublished ? { unpublished: cleanString(entry.unpublished, 200) } : {}) |
| 230 | 234 | } |
| 231 | 235 | } |
| … | … | @@ -54,6 +54,8 @@ class PluginDriveLoader { |
| 54 | 54 | * @param {object} opts |
| 55 | 55 | * @param {object} opts.registry — PearPluginRegistry |
| 56 | 56 | * @param {function} opts.fetchDriveFile — async (driveKeyHex, path) => { content: Buffer } | null |
| 57 | * @param {function} opts.sha256Hex — cryptographic snapshot hash for consent binding | |
| 58 | * @param {function} [opts.refreshDrive] — async (driveKeyHex) => advance cached drive state | |
| 57 | 59 | * @param {function} [opts.persistInstall] — async (id, payload|null) => void; null clears |
| 58 | 60 | * @param {function} [opts.now] |
| 59 | 61 | */ |
| … | … | @@ -64,8 +66,13 @@ class PluginDriveLoader { |
| 64 | 66 | if (typeof opts.fetchDriveFile !== 'function') { |
| 65 | 67 | throw new TypeError('PluginDriveLoader requires a fetchDriveFile transport') |
| 66 | 68 | } |
| 69 | if (typeof opts.sha256Hex !== 'function') { | |
| 70 | throw new TypeError('PluginDriveLoader requires a sha256Hex function') | |
| 71 | } | |
| 67 | 72 | this._registry = opts.registry |
| 68 | 73 | this._fetch = opts.fetchDriveFile |
| 74 | this._sha256Hex = opts.sha256Hex | |
| 75 | this._refreshDrive = typeof opts.refreshDrive === 'function' ? opts.refreshDrive : async () => {} | |
| 69 | 76 | this._persistInstall = typeof opts.persistInstall === 'function' ? opts.persistInstall : async () => {} |
| 70 | 77 | this._now = typeof opts.now === 'function' ? opts.now : Date.now |
| 71 | 78 | this._installs = new Map() // driveKeyHex -> { granted, version, escalated: {added}|null } |
| … | … | @@ -83,7 +90,12 @@ class PluginDriveLoader { |
| 83 | 90 | version: cleanString(record.version), |
| 84 | 91 | installedAt: Number.isFinite(record.installedAt) ? record.installedAt : 0, |
| 85 | 92 | escalated: record.escalated && Array.isArray(record.escalated.added) |
| 86 | ? { added: normalizeCapabilityList(record.escalated.added) } | |
| 93 | ? { | |
| 94 | added: normalizeCapabilityList(record.escalated.added), | |
| 95 | capabilities: normalizeCapabilityList(record.escalated.capabilities), | |
| 96 | version: cleanString(record.escalated.version), | |
| 97 | fingerprint: normalizeFingerprint(record.escalated.fingerprint) | |
| 98 | } | |
| 87 | 99 | : null |
| 88 | 100 | }) |
| 89 | 101 | restored++ |
| … | … | @@ -101,9 +113,10 @@ class PluginDriveLoader { |
| 101 | 113 | } |
| 102 | 114 | |
| 103 | 115 | /** |
| 104 | * Install a plugin from its drive. The user's grant is the manifest's | |
| 105 | * capability set at install time (the consent surface shows exactly this | |
| 106 | * list before calling install). | |
| 116 | * Install through a two-step consent handshake. The first call returns the | |
| 117 | * requested capabilities and a cryptographic snapshot fingerprint without | |
| 118 | * registering anything. Acceptance must echo that fingerprint and an | |
| 119 | * explicit grant, binding consent to the exact bytes the user reviewed. | |
| 107 | 120 | */ |
| 108 | 121 | async installFromDrive (driveKey, opts = {}) { |
| 109 | 122 | const key = normalizeDriveKey(driveKey) |
| … | … | @@ -111,9 +124,11 @@ class PluginDriveLoader { |
| 111 | 124 | |
| 112 | 125 | const loaded = await this._load(key) |
| 113 | 126 | const requested = loaded.capabilities |
| 114 | const granted = opts.grantedCapabilities !== undefined | |
| 115 | ? normalizeCapabilityList(opts.grantedCapabilities).filter(cap => requested.includes(cap)) | |
| 116 | : requested | |
| 127 | const reviewedFingerprint = normalizeFingerprint(opts.reviewedFingerprint) | |
| 128 | if (!reviewedFingerprint || reviewedFingerprint !== loaded.fingerprint || opts.grantedCapabilities === undefined) { | |
| 129 | return consentPreview(key, loaded, reviewedFingerprint ? 'plugin-changed' : 'consent-required') | |
| 130 | } | |
| 131 | const granted = normalizeCapabilityList(opts.grantedCapabilities).filter(cap => requested.includes(cap)) | |
| 117 | 132 | |
| 118 | 133 | const result = this._registry.register({ |
| 119 | 134 | id: key, |
| … | … | @@ -148,7 +163,8 @@ class PluginDriveLoader { |
| 148 | 163 | * Update an installed plugin from its drive. Fail-closed on capability |
| 149 | 164 | * escalation: the plugin is disabled, the escalation is recorded, and the |
| 150 | 165 | * caller gets the added capabilities to show in a re-consent prompt. |
| 151 | * Passing `acceptEscalation: true` re-grants to the new capability set. | |
| 166 | * Acceptance must echo the reviewed snapshot fingerprint and complete | |
| 167 | * capability set; changed bytes produce a new warning instead of a grant. | |
| 152 | 168 | */ |
| 153 | 169 | async updateFromDrive (driveKey, opts = {}) { |
| 154 | 170 | const key = normalizeDriveKey(driveKey) |
| … | … | @@ -158,17 +174,31 @@ class PluginDriveLoader { |
| 158 | 174 | const loaded = await this._load(key) |
| 159 | 175 | const added = loaded.capabilities.filter(cap => !record.granted.includes(cap)) |
| 160 | 176 | |
| 161 | if (added.length > 0 && !opts.acceptEscalation) { | |
| 162 | this._registry.setEnabled(key, false) | |
| 163 | record.escalated = { added } | |
| 164 | await this._persistRecordOnly(key, record) | |
| 165 | return { | |
| 166 | ok: false, | |
| 167 | escalated: true, | |
| 168 | driveKey: key, | |
| 169 | added, | |
| 170 | version: loaded.version, | |
| 171 | message: 'Update requests new capabilities; plugin disabled pending re-consent' | |
| 177 | if (added.length > 0) { | |
| 178 | const reviewedFingerprint = normalizeFingerprint(opts.reviewedFingerprint) | |
| 179 | const acceptedCapabilities = normalizeCapabilityList(opts.grantedCapabilities) | |
| 180 | const pending = record.escalated | |
| 181 | const acceptedSnapshot = pending && | |
| 182 | reviewedFingerprint && | |
| 183 | pending.fingerprint === reviewedFingerprint && | |
| 184 | loaded.fingerprint === reviewedFingerprint && | |
| 185 | arraysEqual(acceptedCapabilities, pending.capabilities) && | |
| 186 | arraysEqual(loaded.capabilities, pending.capabilities) | |
| 187 | ||
| 188 | if (!acceptedSnapshot) { | |
| 189 | this._registry.setEnabled(key, false) | |
| 190 | record.escalated = escalationSnapshot(loaded, added) | |
| 191 | await this._persistRecordOnly(key, record) | |
| 192 | return { | |
| 193 | ok: false, | |
| 194 | escalated: true, | |
| 195 | driveKey: key, | |
| 196 | ...record.escalated, | |
| 197 | changedSinceReview: !!reviewedFingerprint, | |
| 198 | message: reviewedFingerprint | |
| 199 | ? 'Plugin changed since review; inspect the new capabilities before re-consenting' | |
| 200 | : 'Update requests new capabilities; plugin disabled pending re-consent' | |
| 201 | } | |
| 172 | 202 | } |
| 173 | 203 | } |
| 174 | 204 | |
| … | … | @@ -208,6 +238,7 @@ class PluginDriveLoader { |
| 208 | 238 | } |
| 209 | 239 | |
| 210 | 240 | async _load (key) { |
| 241 | await this._refreshDrive(key) | |
| 211 | 242 | const manifestFile = await this._fetch(key, '/manifest.json') |
| 212 | 243 | const manifestBytes = manifestFile && manifestFile.content ? manifestFile.content : null |
| 213 | 244 | if (!manifestBytes || manifestBytes.length === 0) { |
| … | … | @@ -246,11 +277,16 @@ class PluginDriveLoader { |
| 246 | 277 | contribution.filters = await this._readAsset(key, content.filters, MAX_FILTER_BYTES) |
| 247 | 278 | } |
| 248 | 279 | |
| 280 | const version = cleanString(manifest.version) || '0.0.0' | |
| 281 | const fingerprint = normalizeFingerprint(this._sha256Hex(Buffer.from(JSON.stringify({ manifest, contribution })))) | |
| 282 | if (!fingerprint) throw new PluginDriveError('fingerprint-failed', 'Could not fingerprint the plugin snapshot') | |
| 249 | 283 | return { |
| 250 | 284 | manifest, |
| 251 | 285 | capabilities, |
| 252 | 286 | contribution, |
| 253 | version: cleanString(manifest.version) || '0.0.0' | |
| 287 | version, | |
| 288 | fingerprint, | |
| 289 | name: cleanString(manifest.name) || `plugin ${key.slice(0, 8)}…` | |
| 254 | 290 | } |
| 255 | 291 | } |
| 256 | 292 | |
| … | … | @@ -289,6 +325,37 @@ class PluginDriveLoader { |
| 289 | 325 | } |
| 290 | 326 | } |
| 291 | 327 | |
| 328 | function consentPreview (driveKey, loaded, reason) { | |
| 329 | return { | |
| 330 | ok: false, | |
| 331 | consentRequired: true, | |
| 332 | reason, | |
| 333 | driveKey, | |
| 334 | name: loaded.name, | |
| 335 | version: loaded.version, | |
| 336 | requested: loaded.capabilities, | |
| 337 | fingerprint: loaded.fingerprint | |
| 338 | } | |
| 339 | } | |
| 340 | ||
| 341 | function escalationSnapshot (loaded, added) { | |
| 342 | return { | |
| 343 | added: normalizeCapabilityList(added), | |
| 344 | capabilities: [...loaded.capabilities], | |
| 345 | version: loaded.version, | |
| 346 | fingerprint: loaded.fingerprint | |
| 347 | } | |
| 348 | } | |
| 349 | ||
| 350 | function normalizeFingerprint (value) { | |
| 351 | const text = typeof value === 'string' ? value.trim().toLowerCase() : '' | |
| 352 | return /^[0-9a-f]{64}$/.test(text) ? text : '' | |
| 353 | } | |
| 354 | ||
| 355 | function arraysEqual (a, b) { | |
| 356 | return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => value === b[index]) | |
| 357 | } | |
| 358 | ||
| 292 | 359 | function withCapabilities (manifest, capabilities) { |
| 293 | 360 | // The registry derives capabilities from the manifest; scope registration |
| 294 | 361 | // to the GRANTED set so an ungranted capability never reaches the engine. |
| … | … | @@ -38,6 +38,7 @@ class ShieldListSync { |
| 38 | 38 | * @param {object} opts |
| 39 | 39 | * @param {object} opts.shield — ContentShield (addList/removeList) |
| 40 | 40 | * @param {function} opts.fetchDriveFile — async (driveKeyHex, path) => { content: Buffer } | null |
| 41 | * @param {function} [opts.refreshDrive] — async (driveKeyHex) => advance cached drive state | |
| 41 | 42 | * @param {function} [opts.sha256Hex] — (bufferOrString) => hex string; required to verify checksums |
| 42 | 43 | * @param {function} [opts.persistMeta] — async (metaByKey) => void |
| 43 | 44 | * @param {function} [opts.now] — clock injection for tests |
| … | … | @@ -51,6 +52,7 @@ class ShieldListSync { |
| 51 | 52 | } |
| 52 | 53 | this._shield = opts.shield |
| 53 | 54 | this._fetch = opts.fetchDriveFile |
| 55 | this._refreshDrive = typeof opts.refreshDrive === 'function' ? opts.refreshDrive : async () => {} | |
| 54 | 56 | this._sha256Hex = typeof opts.sha256Hex === 'function' ? opts.sha256Hex : null |
| 55 | 57 | this._persistMeta = typeof opts.persistMeta === 'function' ? opts.persistMeta : async () => {} |
| 56 | 58 | this._now = typeof opts.now === 'function' ? opts.now : Date.now |
| … | … | @@ -157,6 +159,7 @@ class ShieldListSync { |
| 157 | 159 | } |
| 158 | 160 | |
| 159 | 161 | async _pull (key, { force }) { |
| 162 | await this._refreshDrive(key) | |
| 160 | 163 | const manifest = await this._readManifest(key) |
| 161 | 164 | const existing = this._meta.get(key) |
| 162 | 165 |
| … | … | @@ -18,6 +18,8 @@ Highlights: |
| 18 | 18 | restore. |
| 19 | 19 | - Pear Plugins for scoped styles, scripts, and network filters. |
| 20 | 20 | - Capability-escalation protection: updates cannot silently gain permissions. |
| 21 | - Snapshot-bound install consent and safe CSS embedding across list/plugin | |
| 22 | boundaries; community catalogues cannot forge the curated trust mark. | |
| 21 | 23 | - One-click P2P catalogue discovery, including anonGPT as an app entry. |
| 22 | 24 | - Per-drive shield allowlists and strict mode through the browser-owned proxy. |
| 23 | 25 |
| … | … | @@ -13,7 +13,7 @@ Module._load = function (request, parent, isMain) { |
| 13 | 13 | if (request === 'bare-http1') return {} |
| 14 | 14 | return origLoad.call(this, request, parent, isMain) |
| 15 | 15 | } |
| 16 | const { HyperProxy } = (await import('../backend/hyper-proxy.js')).default | |
| 16 | const { HyperProxy, escapeStyleText } = (await import('../backend/hyper-proxy.js')).default | |
| 17 | 17 | Module._load = origLoad |
| 18 | 18 | |
| 19 | 19 | const require = createRequire(import.meta.url) |
| … | … | @@ -203,3 +203,24 @@ test('plugin styles and scripts inject only when enabled', async () => { |
| 203 | 203 | assert.doesNotMatch(off, /data-pear-plugin-style/) |
| 204 | 204 | assert.doesNotMatch(off, /data-pear-plugin="plug"/) |
| 205 | 205 | }) |
| 206 | ||
| 207 | test('filter and plugin CSS cannot close the browser-owned style element', async () => { | |
| 208 | const payload = '</style><script>window.__escaped=1</script><style>' | |
| 209 | assert.equal(escapeStyleText(payload).includes('</style>'), false) | |
| 210 | ||
| 211 | const shield = new ContentShield({ builtinList: false }) | |
| 212 | shield.addList('malicious-list', `##${payload}`) | |
| 213 | assert.equal(shield.cosmeticCssFor('example.test'), '') | |
| 214 | ||
| 215 | shield.applyPluginContribution('style-only', { | |
| 216 | styles: { matches: ['*'], css: payload } | |
| 217 | }, ['pear.content.styles']) | |
| 218 | const { proxy } = makeProxy(shield) | |
| 219 | const html = '<html><head></head><body>hi</body></html>' | |
| 220 | const injected = (await proxy._injectHtmlHead(Buffer.from(html), DRIVE, `/hyper/${DRIVE}/index.html`)).toString('utf8') | |
| 221 | const style = injected.match(/<style data-pear-plugin-style>([\s\S]*?)<\/style>/) | |
| 222 | assert.ok(style) | |
| 223 | assert.equal(style[1].includes('</style>'), false) | |
| 224 | assert.equal(injected.includes('<script>window.__escaped=1</script>'), false) | |
| 225 | assert.match(style[1], /\\3c \/style>/) | |
| 226 | }) |
| … | … | @@ -46,26 +46,29 @@ test('builtin seed lists anonGPT with its production drive key, installable by t |
| 46 | 46 | |
| 47 | 47 | test('a catalogue drive loads, validates entries, and merges after builtin', async () => { |
| 48 | 48 | const pluginKey = 'ab'.repeat(32) |
| 49 | const refreshes = [] | |
| 49 | 50 | const catalog = makeCatalog({ |
| 50 | 51 | '/plugins.json': JSON.stringify({ |
| 51 | 52 | name: 'Community Plugins', |
| 52 | 53 | plugins: [ |
| 53 | { driveKey: pluginKey, name: 'Community Styler', kind: 'plugin', capabilities: ['pear.content.styles'], description: 'x' }, | |
| 54 | { driveKey: pluginKey, name: 'Community Styler', kind: 'plugin', capabilities: ['pear.content.styles'], description: 'x', verified: true }, | |
| 54 | 55 | { driveKey: 'not-a-key', name: 'Broken entry' }, |
| 55 | 56 | { name: 'No key at all' }, |
| 56 | 57 | { driveKey: ANONGPT_DRIVE_KEY, name: 'anonGPT shadow attempt', kind: 'app' }, |
| 57 | 58 | 42 |
| 58 | 59 | ] |
| 59 | 60 | }) |
| 60 | }) | |
| 61 | }, { refreshDrive: async (key) => { refreshes.push(key) } }) | |
| 61 | 62 | |
| 62 | 63 | const result = await catalog.loadFromDrive(CAT_KEY) |
| 64 | assert.deepEqual(refreshes, [CAT_KEY]) | |
| 63 | 65 | assert.equal(result.name, 'Community Plugins') |
| 64 | 66 | assert.equal(result.entryCount, 2) // styler + shadow attempt survive validation |
| 65 | 67 | |
| 66 | 68 | const entries = catalog.entries() |
| 67 | 69 | const styler = entries.find(entry => entry.driveKey === pluginKey) |
| 68 | 70 | assert.equal(styler.source, CAT_KEY) |
| 71 | assert.equal(styler.verified, false, 'community sources cannot mint the curated trust mark') | |
| 69 | 72 | |
| 70 | 73 | // Builtin wins the dedupe: the shadow entry cannot replace anonGPT. |
| 71 | 74 | const anongpt = entries.filter(entry => entry.driveKey === ANONGPT_DRIVE_KEY) |
| … | … | @@ -1,6 +1,7 @@ |
| 1 | 1 | import test from 'node:test' |
| 2 | 2 | import assert from 'node:assert/strict' |
| 3 | 3 | import { createRequire } from 'node:module' |
| 4 | import { createHash } from 'node:crypto' | |
| 4 | 5 | |
| 5 | 6 | const require = createRequire(import.meta.url) |
| 6 | 7 | const { ContentShield } = require('../backend/content-shield.cjs') |
| … | … | @@ -22,23 +23,35 @@ function makeLoader (files) { |
| 22 | 23 | const registry = new PearPluginRegistry({ shield }) |
| 23 | 24 | const persisted = new Map() |
| 24 | 25 | const store = { ...files } |
| 26 | const refreshes = [] | |
| 25 | 27 | const loader = new PluginDriveLoader({ |
| 26 | 28 | registry, |
| 27 | 29 | fetchDriveFile: async (driveKey, path) => { |
| 28 | 30 | const value = store[path] |
| 29 | 31 | return value == null ? null : { content: Buffer.from(value) } |
| 30 | 32 | }, |
| 33 | refreshDrive: async (driveKey) => { refreshes.push(driveKey) }, | |
| 34 | sha256Hex: (input) => createHash('sha256').update(input).digest('hex'), | |
| 31 | 35 | persistInstall: async (id, payload) => { |
| 32 | 36 | if (payload === null) persisted.delete(id) |
| 33 | 37 | else persisted.set(id, structuredClone(payload)) |
| 34 | 38 | }, |
| 35 | 39 | now: () => 99 |
| 36 | 40 | }) |
| 37 | return { loader, registry, shield, persisted, store } | |
| 41 | return { loader, registry, shield, persisted, store, refreshes } | |
| 42 | } | |
| 43 | ||
| 44 | async function approveInstall (loader, key = KEY, granted) { | |
| 45 | const preview = await loader.installFromDrive(key) | |
| 46 | assert.equal(preview.consentRequired, true) | |
| 47 | return loader.installFromDrive(key, { | |
| 48 | grantedCapabilities: granted === undefined ? preview.requested : granted, | |
| 49 | reviewedFingerprint: preview.fingerprint | |
| 50 | }) | |
| 38 | 51 | } |
| 39 | 52 | |
| 40 | 53 | test('install from a drive fetches assets and applies the contribution', async () => { |
| 41 | const { loader, registry, shield, persisted } = makeLoader({ | |
| 54 | const { loader, registry, shield, persisted, refreshes } = makeLoader({ | |
| 42 | 55 | '/manifest.json': pluginManifest( |
| 43 | 56 | ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter'], |
| 44 | 57 | { |
| … | … | @@ -52,7 +65,16 @@ test('install from a drive fetches assets and applies the contribution', async ( |
| 52 | 65 | '/filters.txt': '||fixture-ads.example.com^' |
| 53 | 66 | }) |
| 54 | 67 | |
| 55 | const result = await loader.installFromDrive(KEY) | |
| 68 | const preview = await loader.installFromDrive(KEY) | |
| 69 | assert.equal(preview.consentRequired, true) | |
| 70 | assert.deepEqual(refreshes, [KEY]) | |
| 71 | assert.deepEqual(preview.requested, ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter']) | |
| 72 | assert.equal(registry.list().length, 0) | |
| 73 | const result = await loader.installFromDrive(KEY, { | |
| 74 | grantedCapabilities: preview.requested, | |
| 75 | reviewedFingerprint: preview.fingerprint | |
| 76 | }) | |
| 77 | assert.deepEqual(refreshes, [KEY, KEY]) | |
| 56 | 78 | assert.equal(result.ok, true) |
| 57 | 79 | assert.equal(result.version, '1.0.0') |
| 58 | 80 | assert.deepEqual(result.granted, ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter']) |
| … | … | @@ -83,7 +105,7 @@ test('a narrower explicit grant strips ungranted capabilities before the engine' |
| 83 | 105 | '/filters.txt': '||fixture-ads.example.com^' |
| 84 | 106 | }) |
| 85 | 107 | |
| 86 | const result = await loader.installFromDrive(KEY, { grantedCapabilities: ['pear.content.styles'] }) | |
| 108 | const result = await approveInstall(loader, KEY, ['pear.content.styles']) | |
| 87 | 109 | assert.deepEqual(result.granted, ['pear.content.styles']) |
| 88 | 110 | |
| 89 | 111 | // Styles applied, network filter NOT applied (capability was not granted). |
| … | … | @@ -98,7 +120,7 @@ test('an update that escalates capabilities disables the plugin pending re-conse |
| 98 | 120 | }), |
| 99 | 121 | '/style.css': '.fixture-hide { display: none }' |
| 100 | 122 | }) |
| 101 | await loader.installFromDrive(KEY) | |
| 123 | await approveInstall(loader) | |
| 102 | 124 | |
| 103 | 125 | // The drive updates itself over the swarm: same plugin, new powers. |
| 104 | 126 | store['/manifest.json'] = pluginManifest( |
| … | … | @@ -121,13 +143,59 @@ test('an update that escalates capabilities disables the plugin pending re-conse |
| 121 | 143 | assert.equal(loader.installRecord(KEY).escalated.added[0], 'pear.content.scripts') |
| 122 | 144 | |
| 123 | 145 | // Explicit re-consent accepts the escalation and re-enables. |
| 124 | const accepted = await loader.updateFromDrive(KEY, { acceptEscalation: true }) | |
| 146 | const accepted = await loader.updateFromDrive(KEY, { | |
| 147 | grantedCapabilities: outcome.capabilities, | |
| 148 | reviewedFingerprint: outcome.fingerprint | |
| 149 | }) | |
| 125 | 150 | assert.equal(accepted.ok, true) |
| 126 | 151 | assert.equal(accepted.escalationAccepted, true) |
| 127 | 152 | assert.deepEqual(accepted.granted, ['pear.content.styles', 'pear.content.scripts']) |
| 128 | 153 | assert.equal(registry.list().find(item => item.id === KEY).enabled, true) |
| 129 | 154 | }) |
| 130 | 155 | |
| 156 | test('install and escalation consent are bound to the exact reviewed snapshot', async () => { | |
| 157 | const { loader, registry, store } = makeLoader({ | |
| 158 | '/manifest.json': pluginManifest(['pear.content.styles'], { | |
| 159 | styles: { matches: ['*'], path: '/style.css' } | |
| 160 | }), | |
| 161 | '/style.css': '.v1 { color: red }' | |
| 162 | }) | |
| 163 | ||
| 164 | const installPreview = await loader.installFromDrive(KEY) | |
| 165 | store['/style.css'] = '.v2 { color: blue }' | |
| 166 | const changedInstall = await loader.installFromDrive(KEY, { | |
| 167 | grantedCapabilities: installPreview.requested, | |
| 168 | reviewedFingerprint: installPreview.fingerprint | |
| 169 | }) | |
| 170 | assert.equal(changedInstall.consentRequired, true) | |
| 171 | assert.equal(changedInstall.reason, 'plugin-changed') | |
| 172 | assert.equal(registry.list().length, 0) | |
| 173 | ||
| 174 | await approveInstall(loader) | |
| 175 | store['/manifest.json'] = pluginManifest(['pear.content.styles', 'pear.content.scripts'], { | |
| 176 | styles: { matches: ['*'], path: '/style.css' }, | |
| 177 | scripts: { matches: ['*'], path: '/content.js' } | |
| 178 | }, '2.0.0') | |
| 179 | store['/content.js'] = 'window.__v2 = true' | |
| 180 | const warning = await loader.updateFromDrive(KEY) | |
| 181 | ||
| 182 | // Publisher changes the reviewed snapshot before the user accepts it. | |
| 183 | store['/manifest.json'] = pluginManifest(['pear.content.styles', 'pear.content.scripts', 'pear.net.filter'], { | |
| 184 | styles: { matches: ['*'], path: '/style.css' }, | |
| 185 | scripts: { matches: ['*'], path: '/content.js' }, | |
| 186 | filters: '/filters.txt' | |
| 187 | }, '2.1.0') | |
| 188 | store['/filters.txt'] = '||new-power.example^' | |
| 189 | const raced = await loader.updateFromDrive(KEY, { | |
| 190 | grantedCapabilities: warning.capabilities, | |
| 191 | reviewedFingerprint: warning.fingerprint | |
| 192 | }) | |
| 193 | assert.equal(raced.escalated, true) | |
| 194 | assert.equal(raced.changedSinceReview, true) | |
| 195 | assert.deepEqual(raced.added, ['pear.content.scripts', 'pear.net.filter']) | |
| 196 | assert.equal(registry.list().find(item => item.id === KEY).enabled, false) | |
| 197 | }) | |
| 198 | ||
| 131 | 199 | test('a same-capability update hot-swaps without consent friction', async () => { |
| 132 | 200 | const { loader, shield, store } = makeLoader({ |
| 133 | 201 | '/manifest.json': pluginManifest(['pear.content.styles'], { |
| … | … | @@ -135,7 +203,7 @@ test('a same-capability update hot-swaps without consent friction', async () => |
| 135 | 203 | }), |
| 136 | 204 | '/style.css': '.v1 { display: none }' |
| 137 | 205 | }) |
| 138 | await loader.installFromDrive(KEY) | |
| 206 | await approveInstall(loader) | |
| 139 | 207 | |
| 140 | 208 | store['/manifest.json'] = pluginManifest(['pear.content.styles'], { |
| 141 | 209 | styles: { matches: ['*'], path: '/style.css' } |
| … | … | @@ -189,7 +257,7 @@ test('uninstall removes the registration and the durable payload', async () => { |
| 189 | 257 | '/style.css': '.fixture-hide { display: none }', |
| 190 | 258 | '/filters.txt': '||fixture-ads.example.com^' |
| 191 | 259 | }) |
| 192 | await loader.installFromDrive(KEY) | |
| 260 | await approveInstall(loader) | |
| 193 | 261 | assert.equal(shield.shouldBlockUrl('https://fixture-ads.example.com/x.js').blocked, true) |
| 194 | 262 | |
| 195 | 263 | const removed = await loader.uninstall(KEY) |
| … | … | @@ -35,16 +35,25 @@ function makeHarness (dir) { |
| 35 | 35 | const loader = new PluginDriveLoader({ |
| 36 | 36 | registry, |
| 37 | 37 | fetchDriveFile: driveFromDirectory(dir), |
| 38 | sha256Hex: (input) => createHash('sha256').update(input).digest('hex'), | |
| 38 | 39 | persistInstall: async () => {} |
| 39 | 40 | }) |
| 40 | 41 | return { shield, registry, loader } |
| 41 | 42 | } |
| 42 | 43 | |
| 44 | async function approveInstall (loader, key) { | |
| 45 | const preview = await loader.installFromDrive(key) | |
| 46 | return loader.installFromDrive(key, { | |
| 47 | grantedCapabilities: preview.requested, | |
| 48 | reviewedFingerprint: preview.fingerprint | |
| 49 | }) | |
| 50 | } | |
| 51 | ||
| 43 | 52 | test('dark-reader example installs and styles every host', async () => { |
| 44 | 53 | const key = 'a'.repeat(64) |
| 45 | 54 | const { shield, loader } = makeHarness('examples/plugins/dark-reader') |
| 46 | 55 | |
| 47 | const result = await loader.installFromDrive(key) | |
| 56 | const result = await approveInstall(loader, key) | |
| 48 | 57 | assert.equal(result.ok, true) |
| 49 | 58 | assert.deepEqual(result.granted, ['pear.content.styles']) |
| 50 | 59 | |
| … | … | @@ -58,7 +67,7 @@ test('peerit-enhancer example installs scoped to the peerit drive', async () => |
| 58 | 67 | const key = 'b'.repeat(64) |
| 59 | 68 | const { shield, loader } = makeHarness('examples/plugins/peerit-enhancer') |
| 60 | 69 | |
| 61 | const result = await loader.installFromDrive(key) | |
| 70 | const result = await approveInstall(loader, key) | |
| 62 | 71 | assert.equal(result.ok, true) |
| 63 | 72 | assert.deepEqual(result.granted, ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter']) |
| 64 | 73 |
| … | … | @@ -89,6 +89,7 @@ test('catalogue UI lists entries with install/open actions and a source loader', |
| 89 | 89 | test('escalation guard is fail-closed in the loader source', () => { |
| 90 | 90 | const loader = readFileSync(join(root, 'backend/plugin-drive-loader.cjs'), 'utf8') |
| 91 | 91 | assert.match(loader, /setEnabled\(key, false\)/) |
| 92 | assert.match(loader, /acceptEscalation/) | |
| 92 | assert.match(loader, /reviewedFingerprint/) | |
| 93 | assert.match(loader, /acceptedSnapshot/) | |
| 93 | 94 | assert.match(loader, /escalated: true/) |
| 94 | 95 | }) |
| … | … | @@ -25,21 +25,23 @@ function fakeDrive (files) { |
| 25 | 25 | |
| 26 | 26 | function makeSync ({ files, shield } = {}) { |
| 27 | 27 | const persisted = [] |
| 28 | const refreshes = [] | |
| 28 | 29 | const drive = fakeDrive(files || {}) |
| 29 | 30 | const engine = shield || new ContentShield({ builtinList: false }) |
| 30 | 31 | const sync = new ShieldListSync({ |
| 31 | 32 | shield: engine, |
| 32 | 33 | fetchDriveFile: drive.fetch, |
| 34 | refreshDrive: async (driveKey) => { refreshes.push(driveKey) }, | |
| 33 | 35 | sha256Hex, |
| 34 | 36 | persistMeta: async (meta) => { persisted.push(structuredClone(meta)) }, |
| 35 | 37 | now: () => 1234 |
| 36 | 38 | }) |
| 37 | return { sync, shield: engine, drive, persisted } | |
| 39 | return { sync, shield: engine, drive, persisted, refreshes } | |
| 38 | 40 | } |
| 39 | 41 | |
| 40 | 42 | test('subscribe fetches, verifies, registers, and persists a drive list', async () => { |
| 41 | 43 | const filters = '||ads.example.com^\n##.promo\n' |
| 42 | const { sync, shield, persisted } = makeSync({ | |
| 44 | const { sync, shield, persisted, refreshes } = makeSync({ | |
| 43 | 45 | files: { |
| 44 | 46 | '/manifest.json': JSON.stringify({ name: 'pear-default', version: '3', sha256: sha256Hex(filters) }), |
| 45 | 47 | '/filters.txt': filters |
| … | … | @@ -47,6 +49,7 @@ test('subscribe fetches, verifies, registers, and persists a drive list', async |
| 47 | 49 | }) |
| 48 | 50 | |
| 49 | 51 | const result = await sync.subscribe(KEY) |
| 52 | assert.deepEqual(refreshes, [KEY]) | |
| 50 | 53 | assert.equal(result.changed, true) |
| 51 | 54 | assert.equal(result.name, 'pear-default') |
| 52 | 55 | assert.equal(result.version, '3') |
| … | … | @@ -5034,7 +5034,8 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '', onBrowse }) { |
| 5034 | 5034 | const [err, setErr] = useState('') |
| 5035 | 5035 | const [subscribeKey, setSubscribeKey] = useState('') |
| 5036 | 5036 | const [installKey, setInstallKey] = useState('') |
| 5037 | const [escalation, setEscalation] = useState(null) // { driveKey, added: [] } | |
| 5037 | const [pendingInstall, setPendingInstall] = useState(null) | |
| 5038 | const [escalation, setEscalation] = useState(null) | |
| 5038 | 5039 | const [catalog, setCatalog] = useState({ entries: [], sources: [] }) |
| 5039 | 5040 | const [catalogSourceKey, setCatalogSourceKey] = useState('') |
| 5040 | 5041 | const driveKey = typeof activeDriveKey === 'string' && /^[0-9a-f]{64}$/i.test(activeDriveKey) |
| … | … | @@ -5161,12 +5162,18 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '', onBrowse }) { |
| 5161 | 5162 | finally { setBusy(false) } |
| 5162 | 5163 | } |
| 5163 | 5164 | |
| 5164 | const installPluginByKey = async (key) => { | |
| 5165 | const installPluginByKey = async (key, review = null) => { | |
| 5165 | 5166 | const normalized = String(key || '').trim().toLowerCase() |
| 5166 | 5167 | if (!/^[0-9a-f]{64}$/.test(normalized) || C.CMD_PLUGIN_INSTALL_DRIVE == null) return |
| 5167 | 5168 | setBusy(true); setErr('') |
| 5168 | 5169 | try { |
| 5169 | await rpc.request(C.CMD_PLUGIN_INSTALL_DRIVE, { driveKey: normalized }, 30000) | |
| 5170 | const payload = { driveKey: normalized } | |
| 5171 | if (review) { | |
| 5172 | payload.granted = review.requested || [] | |
| 5173 | payload.reviewedFingerprint = review.fingerprint | |
| 5174 | } | |
| 5175 | const outcome = await rpc.request(C.CMD_PLUGIN_INSTALL_DRIVE, payload, 30000) | |
| 5176 | setPendingInstall(outcome && outcome.consentRequired ? outcome : null) | |
| 5170 | 5177 | await refreshStatusAndPlugins() |
| 5171 | 5178 | } catch (e) { setErr(`install: ${e.message}`) } |
| 5172 | 5179 | finally { setBusy(false) } |
| … | … | @@ -5199,12 +5206,17 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '', onBrowse }) { |
| 5199 | 5206 | finally { setBusy(false) } |
| 5200 | 5207 | } |
| 5201 | 5208 | |
| 5202 | const updatePlugin = async (id, acceptEscalation = false) => { | |
| 5209 | const updatePlugin = async (id, review = null) => { | |
| 5203 | 5210 | if (C.CMD_PLUGIN_UPDATE_DRIVE == null) return |
| 5204 | 5211 | setBusy(true); setErr('') |
| 5205 | 5212 | try { |
| 5206 | const outcome = await rpc.request(C.CMD_PLUGIN_UPDATE_DRIVE, { driveKey: id, acceptEscalation }, 30000) | |
| 5207 | setEscalation(outcome && outcome.escalated ? { driveKey: id, added: outcome.added || [] } : null) | |
| 5213 | const payload = { driveKey: id } | |
| 5214 | if (review) { | |
| 5215 | payload.granted = review.capabilities || [] | |
| 5216 | payload.reviewedFingerprint = review.fingerprint | |
| 5217 | } | |
| 5218 | const outcome = await rpc.request(C.CMD_PLUGIN_UPDATE_DRIVE, payload, 30000) | |
| 5219 | setEscalation(outcome && outcome.escalated ? { driveKey: id, ...outcome } : null) | |
| 5208 | 5220 | await refreshStatusAndPlugins() |
| 5209 | 5221 | } catch (e) { setErr(`update: ${e.message}`) } |
| 5210 | 5222 | finally { setBusy(false) } |
| … | … | @@ -5308,7 +5320,7 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '', onBrowse }) { |
| 5308 | 5320 | ${catalog.entries.map((entry) => html` |
| 5309 | 5321 | <div className="settings-row" key=${entry.id} data-testid=${'catalog-entry-' + entry.id}> |
| 5310 | 5322 | <div> |
| 5311 | <div className="settings-label">${entry.name}${entry.verified ? html`<span title="Curated entry" style=${{ marginLeft: '5px', color: '#3fb950', fontSize: '12px' }}>✦</span>` : ''}</div> | |
| 5323 | <div className="settings-label">${entry.name}${entry.source === 'builtin' && entry.verified ? html`<span title="Curated entry" style=${{ marginLeft: '5px', color: '#3fb950', fontSize: '12px' }}>✦</span>` : ''}</div> | |
| 5312 | 5324 | <div className="settings-subtle">${entry.description}</div> |
| 5313 | 5325 | <div className="settings-subtle">${entry.kind === 'app' ? 'P2P app' : 'plugin'}${entry.capabilities?.length ? ` · ${entry.capabilities.join(', ')}` : ''}${entry.source !== 'builtin' ? ` · from ${String(entry.source).slice(0, 8)}…` : ''}</div> |
| 5314 | 5326 | </div> |
| … | … | @@ -5360,10 +5372,20 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '', onBrowse }) { |
| 5360 | 5372 | <button className="btn" data-testid="plugin-install" onClick=${installPlugin} |
| 5361 | 5373 | disabled=${busy || !/^[0-9a-f]{64}$/i.test(installKey.trim())}>Install</button> |
| 5362 | 5374 | </div> |
| 5375 | ${pendingInstall && html` | |
| 5376 | <div className="apps-error" data-testid="plugin-install-consent"> | |
| 5377 | ${pendingInstall.name} ${pendingInstall.version ? `v${pendingInstall.version}` : ''} requests: | |
| 5378 | ${(pendingInstall.requested || []).join(', ') || 'no capabilities'}. | |
| 5379 | Review this grant before installing; catalogue labels are not trusted permissions. | |
| 5380 | <button className="btn small" onClick=${() => installPluginByKey(pendingInstall.driveKey, pendingInstall)} disabled=${busy}>Grant and install</button> | |
| 5381 | <button className="btn small subtle" onClick=${() => setPendingInstall(null)} disabled=${busy}>Cancel</button> | |
| 5382 | </div> | |
| 5383 | `} | |
| 5363 | 5384 | ${escalation && html` |
| 5364 | 5385 | <div className="apps-error" data-testid="plugin-escalation"> |
| 5365 | 5386 | Update for ${escalation.driveKey.slice(0, 12)}… requests new capabilities: ${escalation.added.join(', ')}. |
| 5366 | <button className="btn small" onClick=${() => updatePlugin(escalation.driveKey, true)} disabled=${busy}>Accept and re-enable</button> | |
| 5387 | ${escalation.changedSinceReview ? ' The plugin changed after the previous review; inspect this new request.' : ''} | |
| 5388 | <button className="btn small" onClick=${() => updatePlugin(escalation.driveKey, escalation)} disabled=${busy}>Accept and re-enable</button> | |
| 5367 | 5389 | </div> |
| 5368 | 5390 | `} |
| 5369 | 5391 | ${plugins.map((p) => html` |