foxlend 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pooria Arab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,331 @@
1
- # Temporary Holding Version
1
+ # foxlend
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <p align="center">Lend one login to an AI agent in its own Firefox container, and take it back.</p>
4
+
5
+ <p align="center">
6
+ <a href="https://github.com/pooriaarab/foxlend/actions"><img src="https://github.com/pooriaarab/foxlend/actions/workflows/ci.yml/badge.svg" alt="CI"/></a>
7
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="License MIT"/></a>
8
+ </p>
9
+
10
+ An AI agent in your browser can use every site you are logged in to. foxlend
11
+ gives it one. It makes a new Firefox container for the agent, copies the
12
+ cookies of one site into it, and opens the agent's tab there. Pages in that
13
+ container can reach only the lent site and the hosts that you allow. When
14
+ you revoke the loan, or when its time is up, foxlend closes the agent's tabs
15
+ and removes the container with its cookies and site data. Your own tabs stay
16
+ logged in.
17
+
18
+ foxlend runs in a Firefox extension (Firefox 153 or later, desktop). It
19
+ depends on [foxgate](https://github.com/pooriaarab/foxgate), and each loan
20
+ adds a foxgate grant for the same hosts.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ npm i foxlend
26
+ ```
27
+
28
+ ## Example
29
+
30
+ Put this in the background script of your extension. The permissions are in
31
+ [Firefox APIs used](#firefox-apis-used).
32
+
33
+ ```js
34
+ import { createFoxgate } from "foxgate";
35
+ import { createFoxlend, withDefaultRule } from "foxlend";
36
+
37
+ // Run both at the top level, so Firefox can wake the page for a request or an alarm.
38
+ const publicSuffix = withDefaultRule(browser.publicSuffix);
39
+ const { gate, host } = createFoxgate({ tools: { read_page: "read" }, publicSuffix });
40
+ const lender = createFoxlend({ browser, host, publicSuffix });
41
+ lender.onBlocked.addListener((b) => console.log("blocked", b.type, b.url));
42
+
43
+ async function lendForFifteenMinutes() {
44
+ const loan = await lender.lend({ domain: "www.example.com", scope: "read", ttlMs: 15 * 60_000 });
45
+ console.log(loan.containerName); // "Agent · example.com"
46
+ const action = { tool: "read_page", args: {}, domain: "www.example.com", scope: "read" };
47
+ console.log((await gate.check(action)).decision); // "allow"
48
+ await lender.revoke(loan); // the tab, the cookies, the container, and the grant are gone
49
+ }
50
+ lendForFifteenMinutes();
51
+ ```
52
+
53
+ ## Use cases
54
+
55
+ | Who | What they build | How foxlend helps |
56
+ |---|---|---|
57
+ | A person who lets an agent book travel | An agent that books a flight with one airline account | Lend the airline site for 30 minutes. The agent cannot open your email or your bank, and a page cannot send your data to other hosts. |
58
+ | A caregiver | Help for a parent with a pharmacy or a utility account | Lend that one login to an agent for a task. Revoke it when the task is done. The parent's other sessions are not shared. |
59
+ | A freelancer | One container for each client account, for example each client's CMS | Each loan is a separate container with a separate cookie jar and allow list. Revoke one client without a change to the others. |
60
+ | A QA engineer | Agent tests that run with a test account on staging | Log in once in your own tab. Lend the staging host to each test run with `match: "host"` and a short time limit. The test cannot reach other hosts, such as production. |
61
+ | A researcher or analyst | Read-only research in their own accounts (a bank export, a reading list) | Lend with the `read` scope. The foxgate grant gives read tools only. The allow list stops a page that tries to send the data out. |
62
+ | A browser agent author (for example foxmate) | A personal agent that runs in the user's own Firefox | The agent asks for a login. The user lends it from the sidebar and sees each blocked request. |
63
+ | A security tester | A test page for prompt-injection data theft | The E2E test in this repo is a working example: a hidden injection tries ten ways to send data, and foxlend stops each one. |
64
+
65
+ ## How it works
66
+
67
+ ```mermaid
68
+ flowchart LR
69
+ L["lend(domain, scope, ttlMs, allow)"] --> R[Write the loan record]
70
+ R --> C["New container: Agent · site, purple, fingerprint"]
71
+ C --> K[Copy the site's cookies, expiry capped at the loan end]
72
+ K --> G[Add a foxgate grant for the same hosts]
73
+ G --> T[Open the task tab in the container]
74
+ T --> A{Request from the container}
75
+ A -- host on the allow list --> P[Pass]
76
+ A -- any other host --> B[Cancel and report on onBlocked]
77
+ T --> V["revoke(), the TTL alarm, or the start sweep"]
78
+ V --> X[Close tabs, clear data, remove the container, revoke the grant]
79
+ ```
80
+
81
+ 1. `lend` refuses the loan unless the extension has access to all sites,
82
+ because without it the guard cannot see the loan's requests. If you
83
+ remove that access later in `about:addons`, foxlend revokes every loan.
84
+ Then `lend` finds the site of the domain (the registrable domain,
85
+ eTLD+1) with `browser.publicSuffix`. The loan allows the site, its subdomains, and the
86
+ hosts in `allow`.
87
+ 2. It writes the loan record first, so a crash cannot leave a container that
88
+ no record knows about.
89
+ 3. It creates a container and copies the cookies of that site from your
90
+ default container. It keeps `HttpOnly`, `Secure`, `SameSite`, the path,
91
+ host-only, and the partition key (Total Cookie Protection). Each copy
92
+ expires at the loan end. foxlend never writes to your default container.
93
+ 4. It adds a foxgate grant with the same hosts and the same end time. Your
94
+ agent loop checks each action with that grant.
95
+ 5. It opens the task tab in the container. With `hidden: true`, it hides the
96
+ tab from the tab strip.
97
+ 6. Two guard layers judge each request from the container. A blocking
98
+ `webRequest.onBeforeRequest` listener cancels a request to any host that
99
+ is not on the list. A `proxy.onRequest` listener sends the same requests
100
+ to a SOCKS proxy that does not exist. That layer also stops a
101
+ `<link rel="preconnect">`, which `webRequest` never sees.
102
+ 7. While a loan is active, foxlend turns off two settings for all of
103
+ Firefox: network prediction (DNS prefetch and link prefetch) and WebRTC.
104
+ Neither guard layer sees DNS prefetch or WebRTC traffic. If foxlend
105
+ cannot turn a setting off, for example because another extension
106
+ controls it, `lend` refuses the loan. foxlend gives the settings back
107
+ after the last loan.
108
+ 8. `revoke` blocks every request from the container first. Then it closes
109
+ the tabs, clears the cookies, local storage, and IndexedDB of the
110
+ container, removes the container, and revokes the grant. An alarm runs
111
+ the same revoke at the loan end. When Firefox starts, foxlend revokes the
112
+ loans whose time is over.
113
+
114
+ ```mermaid
115
+ sequenceDiagram
116
+ participant P as Page in the loan tab
117
+ participant F as Firefox
118
+ participant W as foxlend webRequest layer
119
+ participant X as foxlend proxy layer
120
+ participant A as attacker.test
121
+ Note over P: Hidden text tells the agent to send the account number to attacker.test
122
+ P->>F: fetch, image, beacon, WebSocket, frame, redirect, service worker
123
+ F->>W: onBeforeRequest, cookieStoreId = loan container
124
+ W-->>F: cancel (host not on the allow list)
125
+ W-->>W: onBlocked: URL, type, initiator
126
+ P->>F: link rel=preconnect
127
+ F->>X: proxy.onRequest, type speculative
128
+ X-->>F: SOCKS proxy 127.0.0.1:9 (nothing answers)
129
+ Note over A: No request and no connection arrive
130
+ ```
131
+
132
+ Every failure mode has a test or an E2E check. See
133
+ [docs/failure-modes.md](docs/failure-modes.md).
134
+
135
+ ## API
136
+
137
+ This package is a library only. It has no CLI and no MCP server, because
138
+ every part needs WebExtension APIs that exist only inside Firefox.
139
+
140
+ ### `createFoxlend(options)`
141
+
142
+ Call it at the top level of the background script. It returns a frozen
143
+ object.
144
+
145
+ | Option | Default | What it does |
146
+ |---|---|---|
147
+ | `browser` | required | The WebExtension `browser` object. |
148
+ | `host` | required | The foxgate `host`. foxlend calls `addGrant`, `revokeGrant`, and `grants`. |
149
+ | `publicSuffix` | `withDefaultRule(browser.publicSuffix)` | Finds the site of a host. Give foxgate the same object. |
150
+ | `now` | `Date.now` | The clock, in ms since 1970. |
151
+ | `maxTtlMs` | 24 hours | The longest loan. |
152
+ | `proxyLayer` | `true` when `browser.proxy` exists | Add the `proxy.onRequest` layer. |
153
+ | `stopPrediction` | `true` | Turn off network prediction while a loan is active. When foxlend cannot, `lend` throws `setting-failed`. `false` accepts the risk. |
154
+ | `stopWebRtc` | `true` | Turn off WebRTC while a loan is active. When foxlend cannot, `lend` throws `setting-failed`. `false` accepts the risk. |
155
+ | `storageKey` | `"foxlend"` | The `browser.storage.local` key for the loans. |
156
+
157
+ ### The lender
158
+
159
+ | Member | What it does |
160
+ |---|---|
161
+ | `lend(options)` | Lends one login and returns the `Loan`. Throws a `FoxlendError`. |
162
+ | `revoke(loanOrId)` | Takes the login back. Returns `false` when there is no such loan. |
163
+ | `listLoans()` | The active loans, and loans that wait for a revoke to finish. |
164
+ | `sweep()` | Revokes loans whose time is over and removes foxlend containers that no loan holds. foxlend runs it at start. |
165
+ | `onBlocked` | `addListener(fn)`. `fn` gets `{ loanId, url, host, type, initiator, layer, reason, at }`. |
166
+ | `onRevoked` | `addListener(fn)`. `fn` gets `{ loan, reason }`. `reason` is `user`, `ttl`, `startup`, or `permission`. |
167
+
168
+ ### `lend` options
169
+
170
+ | Option | Default | What it does |
171
+ |---|---|---|
172
+ | `domain` | required | The host to lend, for example `www.example.com`. |
173
+ | `scope` | required | The foxgate scope of the grant: `read`, `fill`, `submit`, or `pay`. |
174
+ | `ttlMs` | required | How long the loan lasts, in ms. |
175
+ | `allow` | `[]` | More hosts that pages in the loan may reach: exact hosts, or `*.` plus a host. |
176
+ | `url` | `https://<domain>/` | The task URL. It must be on a host that the loan allows. |
177
+ | `hidden` | `false` | Hide the task tab. Needs the `tabHide` permission. |
178
+ | `match` | `"site"` | `"site"` lends the registrable domain and its subdomains. `"host"` lends the exact host only, with only the cookies that host gets. |
179
+ | `tools` | every tool | The tool names that the foxgate grant allows. |
180
+
181
+ A `Loan` has these fields:
182
+
183
+ | Field | What it is |
184
+ |---|---|
185
+ | `id`, `state` | The loan ID, and `creating`, `active`, or `revoking`. |
186
+ | `domain`, `site`, `match` | The lent host, its registrable domain, and the match mode. |
187
+ | `patterns` | The allow list: the lent site and the `allow` hosts. |
188
+ | `scope`, `grantId` | The foxgate scope and the ID of the grant. |
189
+ | `cookieStoreId`, `containerName` | The loan container. |
190
+ | `url`, `tabId`, `hidden` | The task tab. |
191
+ | `createdAt`, `expiresAt` | The start and the end, in ms since 1970. |
192
+ | `copied`, `skipped` | The number of cookies copied, and the cookies not copied, each with a reason. |
193
+
194
+ ### Errors
195
+
196
+ `FoxlendError` has a `code`: `bad-domain`, `bad-allow`, `bad-ttl`, `bad-url`,
197
+ `bad-scope`, `setting-failed`, `no-host-access`, `lend-failed`, `revoke-failed`, or
198
+ `storage-error`. A failed
199
+ lend undoes its steps. A failed revoke keeps blocking the container, and the
200
+ next start tries again.
201
+
202
+ ### Other exports
203
+
204
+ | Export | What it does |
205
+ |---|---|
206
+ | `withDefaultRule(publicSuffix)` | Adds the default rule of the public suffix list. Firefox returns `null` for hosts on top-level domains that are not on the list, such as `.test` and `.localhost`. |
207
+ | `siteOf(host, publicSuffix)` | The registrable domain of a host. |
208
+ | `loanPatterns(input)` | The host patterns of a loan. |
209
+ | `planCopy(cookies, options)` | The `cookies.set` calls that copy one site's cookies. |
210
+ | `judge(request, loans, now, publicSuffix)` | The guard decision for one request. |
211
+ | `DEAD_PROXY`, `CONTAINER` | The proxy that blocked requests go to, and the container name prefix, color, and icon. |
212
+
213
+ ### Demo extension
214
+
215
+ `extension/` is a demo for Firefox 153+. Its sidebar lends the current site
216
+ with a scope, a time limit, and an allow list. It lists the active loans with
217
+ Revoke, and it shows a live log of blocked requests.
218
+
219
+ ```bash
220
+ pnpm install
221
+ pnpm e2e # the full pitch flow in Firefox; writes artifacts/e2e-<date>.json
222
+ pnpm build:ext # builds dist-ext/; load it from about:debugging
223
+ ```
224
+
225
+ The E2E test serves a bank on `www.bank.localhost` with a login, and an
226
+ attacker on `attacker.test`. You log in to the bank in your own tab. Then the
227
+ test lends the bank from the sidebar. The agent tab opens logged in, on a page
228
+ with a hidden prompt injection. The page tries to send the account number to
229
+ `attacker.test` in nine ways: fetch, an image, a beacon, a WebSocket, a
230
+ frame, a redirect, a service worker, a preconnect, and a link prefetch. Each
231
+ one is blocked and logged. The attacker server gets no request and no
232
+ connection. The page also tries WebRTC to a local UDP listener. Before the
233
+ loan, your own tab reaches that listener, so the probe works. During the
234
+ loan, the page sends it no packet. Your own
235
+ tab can still reach `attacker.test`. After Revoke, the container is gone, and
236
+ your own tab is still logged in with the same cookies.
237
+
238
+ ## Firefox APIs used
239
+
240
+ | API | MDN | Why |
241
+ |---|---|---|
242
+ | `contextualIdentities.create`, `get`, `query`, `remove` | [contextualIdentities](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/contextualIdentities) | Make one container for each loan, and remove it. |
243
+ | `cookies.getAll`, `set`, `remove` with `storeId`, `partitionKey`, `firstPartyDomain` | [cookies](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies) | Read your cookies, copy one site's cookies into the loan container, and keep partitioned cookies in their partition. |
244
+ | `webRequest.onBeforeRequest` (blocking, `details.cookieStoreId`) | [webRequest](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/webRequest/onBeforeRequest) | Cancel each request from a loan container to a host that is not on the list. Permissions `webRequest` and `webRequestBlocking`. |
245
+ | `proxy.onRequest` (`details.cookieStoreId`) | [proxy.onRequest](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/proxy/onRequest) | The second layer. It also stops speculative connections. |
246
+ | `privacy.network.networkPredictionEnabled` | [privacy.network](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/privacy/network) | Turn off DNS prefetch and link prefetch while a loan is active. |
247
+ | `privacy.network.peerConnectionEnabled` | [privacy.network](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/privacy/network) | Turn off WebRTC while a loan is active. Its UDP traffic passes neither guard layer. |
248
+ | `browsingData.remove` with `cookieStoreId` | [browsingData](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData/remove) | Clear the cookies, local storage, and IndexedDB of the container. |
249
+ | `tabs.create` with `cookieStoreId`, `tabs.query`, `tabs.remove` | [tabs.create](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create) | Open the task tab in the container, and close every tab of the loan. |
250
+ | `tabs.hide` | [tabs.hide](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/hide) | Hide the task tab. Permission `tabHide`. |
251
+ | `publicSuffix.getDomain`, `getKnownSuffix` | [publicSuffix](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/publicSuffix) | Find the site of a host with no bundled list (Firefox 153+). |
252
+ | `permissions.contains`, `permissions.onRemoved` | [permissions](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/permissions) | Refuse a loan without access to all sites, and revoke every loan when the user removes that access. |
253
+ | `alarms` | [alarms](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/alarms) | Revoke a loan at its end, also when the event page is not loaded. |
254
+ | `runtime.onStartup` | [runtime.onStartup](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onStartup) | Revoke loans whose time ended while Firefox was closed. |
255
+ | `storage.local` | [storage.local](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/local) | Keep the loan records. |
256
+ | `storage.session`, `runtime.sendMessage`, `runtime.onMessage` | [runtime](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime) | The sidebar talks to the background page and reads the blocked log. Demo only. |
257
+ | `sidebarAction` (`sidebar_action`) | [sidebarAction](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/sidebarAction) | The demo sidebar. Demo only. |
258
+ | `tabs.captureTab` | [tabs.captureTab](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/captureTab) | The E2E test takes a screenshot of the loan tab. Test only. |
259
+
260
+ ## Limits
261
+
262
+ - A lent cookie still lets the agent act as you on that site. The allow list
263
+ limits where data can go. It does not limit what the agent does on the
264
+ lent site. The foxgate scope works only when your agent loop asks the gate
265
+ before each action.
266
+ - Data can still leave through a host that is allowed, for example as a
267
+ comment on the lent site or an upload to an allowed host.
268
+ - Sessions that are bound to the device may fail in the container: client
269
+ certificates, sessions bound to an IP address or a TLS channel, and logins
270
+ that need a passkey at each use.
271
+ - foxlend does not change the cookies in your default container. The site
272
+ can still end your session on its side, for example when it rotates a
273
+ session token that the agent uses.
274
+ - Android has no containers, so foxlend works on desktop Firefox only.
275
+ - foxlend does not detect DNS rebinding. The allow list trusts the DNS of
276
+ the hosts on it.
277
+ - WebRTC and network prediction are off for all of Firefox while a loan is
278
+ active, also in your own tabs. No page can start a WebRTC call, for
279
+ example a video call, until the last loan ends. Without these settings off, WebRTC sends UDP
280
+ packets that neither guard layer sees (seen in Firefox 157).
281
+ - The network prediction setting does not stop everything. A
282
+ `<link rel="preconnect">` still happens with the setting off (seen in Firefox 157). The proxy layer stops
283
+ it. The E2E test cannot see DNS lookups, so it does not prove that DNS
284
+ prefetch stops.
285
+ - Firefox cannot clear service workers for one container. Service worker
286
+ data of the loan container can stay on disk after a revoke. Firefox did not
287
+ use the removed container ID again in our tests.
288
+ - The guard covers pages in the loan container. It does not cover your agent
289
+ code, for example the calls from your extension to a model API.
290
+ - The start sweep removes every container whose name starts with
291
+ `Agent · ` and that is purple with the fingerprint icon, when no loan holds
292
+ it. Do not use that combination for your own containers.
293
+ - When a site uses only `Secure` cookies, copies need an `https:` URL. The
294
+ E2E test uses `http:` sites on `.localhost`, so `Secure` cookies and
295
+ first-party isolation are covered by the Node tests only.
296
+ - Extensions that move tabs between containers (for example Multi-Account
297
+ Containers or Temporary Containers) can open a URL that foxlend blocked in
298
+ another container, outside the loan. Do not use them with foxlend.
299
+ - When foxlend cannot read its loan records (a storage error), the guard
300
+ blocks requests from all your containers other than the default one, not
301
+ only the loan containers. This is on purpose: it fails closed.
302
+ - Run one lender, in the background page. Two lenders on the same storage,
303
+ for example one in a sidebar and one in the background, can overwrite each
304
+ other's records.
305
+ - The proxy layer was not tested together with another extension that sets
306
+ a proxy.
307
+ - If the TTL ends while Firefox is closed, the revoke happens at the next
308
+ start. The copied cookies have already expired by then. Cookies that the
309
+ site set in the loan container during the loan (for example a new session
310
+ token) are not capped, so they stay valid until that revoke. The same
311
+ holds when a revoke fails or the extension is off.
312
+
313
+ ## Part of the fox primitives
314
+
315
+ ```mermaid
316
+ flowchart LR
317
+ foxkit[foxkit] -- template --> foxlend[foxlend]
318
+ foxgate[foxgate] --> foxlend
319
+ foxlend --> foxmate[foxmate]
320
+ click foxkit "https://github.com/pooriaarab/foxkit"
321
+ click foxgate "https://github.com/pooriaarab/foxgate"
322
+ click foxlend "https://github.com/pooriaarab/foxlend"
323
+ click foxmate "https://github.com/pooriaarab/foxmate"
324
+ ```
325
+
326
+ foxlend depends on foxgate for the grant that matches each loan. foxmate,
327
+ the reference agent, lends logins with foxlend.
328
+
329
+ ## License
330
+
331
+ [MIT](LICENSE)
@@ -0,0 +1,129 @@
1
+ import type { Cookie, CookieSetDetails } from "./cookies.js";
2
+ import type { PublicSuffixApi } from "./site.js";
3
+ export interface BrowserEvent<F> {
4
+ addListener(fn: F, ...rest: unknown[]): void;
5
+ }
6
+ /** The fields of webRequest and proxy request details that foxlend reads. */
7
+ export interface RequestDetails {
8
+ url: string;
9
+ type: string;
10
+ cookieStoreId?: string;
11
+ originUrl?: string;
12
+ documentUrl?: string;
13
+ tabId?: number;
14
+ }
15
+ export interface ContextualIdentity {
16
+ name: string;
17
+ color: string;
18
+ icon: string;
19
+ cookieStoreId: string;
20
+ }
21
+ export interface ProxyInfo {
22
+ type: string;
23
+ host: string;
24
+ port: number;
25
+ proxyDNS?: boolean;
26
+ }
27
+ /** A browser-wide setting from `browser.privacy`. */
28
+ export interface BrowserSetting {
29
+ get(details: object): Promise<{
30
+ value: unknown;
31
+ levelOfControl: string;
32
+ }>;
33
+ set(details: {
34
+ value: boolean;
35
+ }): Promise<boolean>;
36
+ clear(details: object): Promise<boolean>;
37
+ }
38
+ /** The browser-wide settings that foxlend turns off while a loan is active (E6, E17). */
39
+ export type BrowserSettingName = "networkPredictionEnabled" | "peerConnectionEnabled";
40
+ type Maybe<T> = T | undefined | Promise<T | undefined>;
41
+ export interface BrowserLike {
42
+ storage: {
43
+ local: {
44
+ get(key: string): Promise<Record<string, unknown>>;
45
+ set(items: Record<string, unknown>): Promise<void>;
46
+ };
47
+ };
48
+ contextualIdentities: {
49
+ create(details: {
50
+ name: string;
51
+ color: string;
52
+ icon: string;
53
+ }): Promise<ContextualIdentity>;
54
+ remove(cookieStoreId: string): Promise<unknown>;
55
+ query(details: {
56
+ name?: string;
57
+ }): Promise<ContextualIdentity[]>;
58
+ get(cookieStoreId: string): Promise<ContextualIdentity>;
59
+ };
60
+ cookies: {
61
+ getAll(details: Record<string, unknown>): Promise<Cookie[]>;
62
+ set(details: CookieSetDetails): Promise<unknown>;
63
+ remove(details: Record<string, unknown>): Promise<unknown>;
64
+ };
65
+ tabs: {
66
+ create(details: {
67
+ url: string;
68
+ cookieStoreId: string;
69
+ active: boolean;
70
+ }): Promise<{
71
+ id?: number;
72
+ }>;
73
+ query(details: {
74
+ cookieStoreId: string;
75
+ }): Promise<{
76
+ id?: number;
77
+ }[]>;
78
+ remove(tabIds: number[]): Promise<void>;
79
+ hide(tabIds: number[]): Promise<unknown>;
80
+ };
81
+ browsingData: {
82
+ remove(options: {
83
+ cookieStoreId: string;
84
+ }, types: Record<string, boolean>): Promise<void>;
85
+ };
86
+ alarms: {
87
+ create(name: string, info: {
88
+ when: number;
89
+ }): unknown;
90
+ clear(name: string): Promise<boolean>;
91
+ onAlarm: BrowserEvent<(alarm: {
92
+ name: string;
93
+ }) => void>;
94
+ };
95
+ webRequest: {
96
+ onBeforeRequest: BrowserEvent<(details: RequestDetails) => Maybe<{
97
+ cancel: boolean;
98
+ }>>;
99
+ };
100
+ proxy?: {
101
+ onRequest: BrowserEvent<(details: RequestDetails) => Maybe<ProxyInfo>>;
102
+ };
103
+ privacy?: {
104
+ network: Record<BrowserSettingName, BrowserSetting | undefined>;
105
+ };
106
+ permissions: {
107
+ contains(permissions: {
108
+ origins: string[];
109
+ }): Promise<boolean>;
110
+ onRemoved: BrowserEvent<(permissions: {
111
+ origins?: string[];
112
+ }) => void>;
113
+ };
114
+ runtime: {
115
+ onStartup: BrowserEvent<() => void>;
116
+ };
117
+ publicSuffix?: PublicSuffixApi;
118
+ }
119
+ /** An event that foxlend fires. */
120
+ export interface Listenable<T> {
121
+ addListener(fn: (event: T) => void): void;
122
+ removeListener(fn: (event: T) => void): void;
123
+ }
124
+ /** A listener that throws does not stop the other listeners. */
125
+ export declare function emitter<T>(): {
126
+ event: Listenable<T>;
127
+ emit(value: T): void;
128
+ };
129
+ export {};
@@ -0,0 +1,17 @@
1
+ /** A listener that throws does not stop the other listeners. */
2
+ export function emitter() {
3
+ const fns = new Set();
4
+ return {
5
+ event: { addListener: (fn) => void fns.add(fn), removeListener: (fn) => void fns.delete(fn) },
6
+ emit(value) {
7
+ for (const fn of fns) {
8
+ try {
9
+ fn(value);
10
+ }
11
+ catch {
12
+ // One broken listener must not hide the event from the others.
13
+ }
14
+ }
15
+ },
16
+ };
17
+ }
@@ -0,0 +1,62 @@
1
+ import { type PublicSuffix } from "foxgate";
2
+ /** A cookie as Firefox `cookies.getAll` returns it. */
3
+ export interface Cookie {
4
+ name: string;
5
+ value: string;
6
+ domain: string;
7
+ hostOnly: boolean;
8
+ path: string;
9
+ secure: boolean;
10
+ httpOnly: boolean;
11
+ sameSite: "no_restriction" | "lax" | "strict" | "unspecified";
12
+ session: boolean;
13
+ expirationDate?: number;
14
+ firstPartyDomain?: string;
15
+ partitionKey?: {
16
+ topLevelSite?: string;
17
+ hasCrossSiteAncestor?: boolean;
18
+ } | null;
19
+ storeId: string;
20
+ }
21
+ /** The details for one Firefox `cookies.set` call. */
22
+ export interface CookieSetDetails {
23
+ url: string;
24
+ name: string;
25
+ value: string;
26
+ domain?: string;
27
+ path: string;
28
+ secure: boolean;
29
+ httpOnly: boolean;
30
+ sameSite: Cookie["sameSite"];
31
+ expirationDate: number;
32
+ storeId: string;
33
+ firstPartyDomain?: string;
34
+ partitionKey?: {
35
+ topLevelSite?: string;
36
+ hasCrossSiteAncestor?: boolean;
37
+ };
38
+ }
39
+ export interface SkippedCookie {
40
+ name: string;
41
+ domain: string;
42
+ /** "other-partition": from another top-level site. "not-allowed": a third party not on the allow list. "set-failed": Firefox refused the copy. */
43
+ reason: "expired" | "other-partition" | "not-allowed" | "set-failed";
44
+ message?: string;
45
+ }
46
+ export interface CopyOptions {
47
+ host: string;
48
+ match: "site" | "host";
49
+ /** From loanPatterns(). */
50
+ patterns: string[];
51
+ /** The loan container. */
52
+ storeId: string;
53
+ /** The loan end, in ms since 1970. */
54
+ endsAt: number;
55
+ now: number;
56
+ publicSuffix: PublicSuffix;
57
+ }
58
+ /** The cookies.set() calls that copy one site's cookies into the loan container. */
59
+ export declare function planCopy(cookies: Cookie[], options: CopyOptions): {
60
+ set: CookieSetDetails[];
61
+ skipped: SkippedCookie[];
62
+ };
@@ -0,0 +1,75 @@
1
+ // Which cookies a loan gets, and how (docs/failure-modes.md K1-K9, K11).
2
+ // planCopy only reads the cookies of the default container. It returns the
3
+ // cookies.set() calls for the loan container and never touches the source.
4
+ import { matchesPattern, parsePattern } from "foxgate";
5
+ import { hostOf, siteOf, within } from "./site.js";
6
+ const partitionSite = (topLevelSite, publicSuffix) => {
7
+ try {
8
+ return siteOf(new URL(topLevelSite).hostname, publicSuffix);
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ };
14
+ /** The cookies.set() calls that copy one site's cookies into the loan container. */
15
+ export function planCopy(cookies, options) {
16
+ const host = hostOf(options.host);
17
+ const site = siteOf(host, options.publicSuffix);
18
+ const allowed = options.patterns.map((p) => parsePattern(p, options.publicSuffix));
19
+ const set = [];
20
+ const skipped = [];
21
+ for (const cookie of cookies) {
22
+ const domain = cookie.domain.replace(/^\./, "").toLowerCase();
23
+ const skip = (reason) => skipped.push({ name: cookie.name, domain: cookie.domain, reason });
24
+ // First-party isolation: a cookie of the site kept for another first party (K14).
25
+ if (cookie.firstPartyDomain && cookie.firstPartyDomain !== site) {
26
+ if (within(domain, site))
27
+ skip("other-partition");
28
+ continue;
29
+ }
30
+ // match "host": only what the browser sends to this host (K11, K13).
31
+ const hostGets = cookie.hostOnly ? domain === host : within(host, domain);
32
+ const topLevelSite = cookie.partitionKey?.topLevelSite;
33
+ if (topLevelSite) {
34
+ if (partitionSite(topLevelSite, options.publicSuffix) !== site) {
35
+ if (within(domain, site))
36
+ skip("other-partition");
37
+ continue;
38
+ }
39
+ if (within(domain, site)) {
40
+ if (options.match === "host" && !hostGets)
41
+ continue;
42
+ }
43
+ else if (!allowed.some((p) => matchesPattern(domain, p))) {
44
+ skip("not-allowed");
45
+ continue;
46
+ }
47
+ }
48
+ else {
49
+ if (!within(domain, site))
50
+ continue;
51
+ if (options.match === "host" && !hostGets)
52
+ continue;
53
+ }
54
+ const expires = cookie.session || cookie.expirationDate === undefined ? Infinity : cookie.expirationDate;
55
+ if (expires * 1000 <= options.now) {
56
+ skip("expired");
57
+ continue;
58
+ }
59
+ set.push({
60
+ url: `${cookie.secure ? "https" : "http"}://${domain}${cookie.path}`,
61
+ name: cookie.name,
62
+ value: cookie.value,
63
+ ...(cookie.hostOnly ? {} : { domain: cookie.domain }),
64
+ path: cookie.path,
65
+ secure: cookie.secure,
66
+ httpOnly: cookie.httpOnly,
67
+ sameSite: cookie.sameSite,
68
+ expirationDate: Math.min(expires, options.endsAt / 1000),
69
+ storeId: options.storeId,
70
+ ...(cookie.firstPartyDomain === undefined ? {} : { firstPartyDomain: cookie.firstPartyDomain }),
71
+ ...(cookie.partitionKey ? { partitionKey: cookie.partitionKey } : {}),
72
+ });
73
+ }
74
+ return { set, skipped };
75
+ }
@@ -0,0 +1,32 @@
1
+ import { type PublicSuffix } from "foxgate";
2
+ /** What the guard needs to know about a loan. */
3
+ export interface LoanState {
4
+ id: string;
5
+ /** Not set until the container exists. */
6
+ cookieStoreId?: string;
7
+ /** From loanPatterns(). */
8
+ patterns: string[];
9
+ expiresAt: number;
10
+ state: "creating" | "active" | "revoking";
11
+ }
12
+ /** The fields of a webRequest or proxy request details object that judge() reads. */
13
+ export interface RequestInfo {
14
+ url: string;
15
+ type: string;
16
+ cookieStoreId?: string;
17
+ }
18
+ export type BlockReason = "not-allowed" | "expired" | "revoking" | "bad-url" | "no-state" | "error";
19
+ export type Verdict = {
20
+ block: false;
21
+ } | {
22
+ block: true;
23
+ loanId: string;
24
+ reason: BlockReason;
25
+ host?: string;
26
+ };
27
+ /**
28
+ * Decide one request. `loans` undefined means the loan list could not be
29
+ * read: then every container other than the default and the private one is
30
+ * blocked (E13).
31
+ */
32
+ export declare function judge(request: RequestInfo, loans: LoanState[] | undefined, now: number, publicSuffix: PublicSuffix): Verdict;