agent-dag 1.47.0 → 3.0.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.
@@ -0,0 +1,502 @@
1
+ // "Can somebody else's Claude Code drive the browser on this machine?" — read
2
+ // off disk, answered without a single privileged call, and turned into the one
3
+ // command that closes it.
4
+ //
5
+ // THE EXPOSURE. The Claude in Chrome extension holds a cloud relay open to
6
+ // bridge.claudeusercontent.com, and it registers the browser under the ANTHROPIC
7
+ // ACCOUNT rather than under the device. So the set of clients able to enumerate
8
+ // and drive this browser is not "the sessions on this laptop", it is "every
9
+ // Claude Code session signed in to that account, on any machine" — and the
10
+ // pairing completes with no prompt on the machine being taken over. Upstream:
11
+ // anthropics/claude-code #25551, #33813, #42660. The browser on the other end is
12
+ // the one holding the user's logged-in sessions, so the question is worth a
13
+ // panel rather than a footnote.
14
+ //
15
+ // WHAT THIS MODULE DOES. Two reads and a string. It says whether the extension
16
+ // is installed and what it was granted (from a profile's "Secure Preferences",
17
+ // which the caller parses), and whether the hosts file already black-holes the
18
+ // relay (from the hosts file's TEXT, which the caller reads). Neither needs a
19
+ // privilege. Then it hands back the command that would change it, as text, for
20
+ // the user to paste.
21
+ //
22
+ // WHY IT NEVER ELEVATES AND NEVER WRITES. Not squeamishness — the deck's own
23
+ // threat model. `isTrustedMutation` in index.mjs deliberately lets a request
24
+ // carrying no Origin header through (`if (!hasOrigin && !site) return true;`)
25
+ // so that hook.js and curl keep working, on the reasoning that a process able
26
+ // to POST to loopback can already run anything as the user. That reasoning
27
+ // holds only while no route can do something the caller could not do for
28
+ // itself. The moment one route can raise a password dialog, any local process
29
+ // gets to make an authentication prompt appear wearing ccdeck's name, at a
30
+ // moment ccdeck chose — which is the whole of a phishing primitive, handed over
31
+ // for free. There is no elevation anywhere else in this repo, and this module
32
+ // is not where the precedent starts. It imports node:path and nothing else: no
33
+ // child_process, no fs, nothing that could run or write. relay-guard.test.ts
34
+ // pins that by reading this file's own source, because "we would notice" is not
35
+ // a control.
36
+ import { win32 as winPath } from "node:path";
37
+
38
+ /** The relay the extension keeps open. Blocking this name is the whole lever:
39
+ * the pairing is account-scoped, so there is no per-device setting to turn off
40
+ * and no token to rotate — DNS is the seam. */
41
+ export const RELAY_HOST = "bridge.claudeusercontent.com";
42
+
43
+ /** Claude in Chrome, by its Web Store extension id — the key under
44
+ * `extensions.settings` in a Chromium profile's "Secure Preferences". */
45
+ export const CLAUDE_EXT_ID = "fcoeoabgfenejglbffodgkkbkcdhcgfn";
46
+
47
+ /** The tag that makes a hosts line OURS rather than somebody's. Everything
48
+ * downstream — what `readKillswitch` will claim, what the unblock command is
49
+ * allowed to delete — hangs off this exact text appearing as a comment on the
50
+ * line. A line without it is a line we did not write and will not remove. */
51
+ const TAG = "ccdeck killswitch";
52
+
53
+ /** The line the block command appends, verbatim. Also the line the unblock
54
+ * command deletes, and the only one it may. */
55
+ const KILLSWITCH_LINE = `0.0.0.0 ${RELAY_HOST} # ${TAG}`;
56
+
57
+ /**
58
+ * The host name with its dots escaped, for use inside a pattern.
59
+ *
60
+ * THIS IS THE ONE THAT ALREADY DID DAMAGE. The shell tool this feature descends
61
+ * from matched the host with its dots unescaped, so `.` meant "any character"
62
+ * and the pattern also matched `bridge-claudeusercontent-com` — and, being
63
+ * unanchored on top of that, matched it as a SUBSTRING of unrelated lines. One
64
+ * test run deleted five entries from a real /etc/hosts, one of them an internal
65
+ * network mapping that nothing else on that machine knew how to reproduce.
66
+ *
67
+ * Escaping only `.` is sufficient here and not in general: RELAY_HOST is
68
+ * letters and dots, which are otherwise literal in all three dialects this
69
+ * module writes patterns for (JavaScript, POSIX BRE for sed, .NET for
70
+ * PowerShell). A test pins that character-set assumption, so changing the
71
+ * constant to something containing a metacharacter fails there rather than
72
+ * silently widening every matcher at once.
73
+ */
74
+ const HOST_RE = RELAY_HOST.replace(/\./g, "\\.");
75
+
76
+ /**
77
+ * The tagged line, as a pattern written in the dialect JavaScript and .NET
78
+ * share — so the matcher below and the delete pattern inside the Windows
79
+ * command are the SAME string and cannot drift apart.
80
+ *
81
+ * Anchored at both ends, dots escaped, and the tag required. Each of those
82
+ * three is load-bearing:
83
+ *
84
+ * ^[ \t]* a hosts line may be indented and still be live, but a line whose
85
+ * first non-blank character is `#` is a comment, and this refuses
86
+ * it — `# 0.0.0.0 bridge… # ccdeck killswitch` is a block somebody
87
+ * turned OFF, and claiming it would report protection that is not
88
+ * there and then delete a line that was already inert.
89
+ * [ \t]+ one or more, because the file on this machine separates fields
90
+ * with a tab and a hand-edited line may use several spaces.
91
+ * $ without it, `0.0.0.0 bridge… # ccdeck killswitch AND SOMETHING`
92
+ * counts as ours, and the unblock deletes the something with it.
93
+ * the tag without it, every untagged mapping of the host is ours to delete,
94
+ * including the one an admin put there on purpose.
95
+ */
96
+ const TAGGED_PATTERN = `^[ \\t]*0\\.0\\.0\\.0[ \\t]+${HOST_RE}[ \\t]+#[ \\t]*${TAG}[ \\t]*$`;
97
+
98
+ /** The same shape in POSIX BRE, for sed. `[[:space:]]` rather than `[ \t]`
99
+ * because BRE has no `\t` escape — a bracket expression written `[ \t]` in a
100
+ * BRE matches a backslash and the letter t, which is not what anyone reading
101
+ * it would think, and `+` is not a BRE repetition operator either, hence the
102
+ * `XX*` spelling. */
103
+ const TAGGED_BRE = "^[[:space:]]*0\\.0\\.0\\.0[[:space:]][[:space:]]*" + HOST_RE +
104
+ "[[:space:]][[:space:]]*#[[:space:]]*" + TAG + "[[:space:]]*$";
105
+
106
+ /** Case-SENSITIVE on purpose, and this is the asymmetry the module turns on:
107
+ * what counts as ours drives a DELETE, so it is exactly the line we write;
108
+ * what counts as foreign drives a WARNING, so it is generous (see below). Both
109
+ * shell dialects match case-sensitively too — sed by default, PowerShell only
110
+ * because the command asks for `-cnotmatch` rather than `-notmatch` — so a
111
+ * line this claims is a line those two will actually remove. */
112
+ const OURS = new RegExp(TAGGED_PATTERN);
113
+
114
+ /**
115
+ * Addresses that make a mapping a block rather than a redirect.
116
+ *
117
+ * `0.0.0.0` is the one we write and the one every hosts blocklist uses: nothing
118
+ * dials it, so the connection fails immediately instead of hanging. The
119
+ * loopback pair is here because it is the older convention for the same intent
120
+ * and a user who typed one meant to block; the relay speaks TLS on 443 and
121
+ * nothing on this machine answers there, so it fails too — just a little later.
122
+ *
123
+ * Everything else is treated as reachable, including a private address that
124
+ * happens to be down today. This module cannot tell a sinkhole from a proxy by
125
+ * looking at an octet, and the honest failure direction is to under-claim
126
+ * protection rather than to over-claim it.
127
+ */
128
+ const BLACK_HOLE = new Set(["0.0.0.0", "127.0.0.1", "::1", "::"]);
129
+
130
+ /**
131
+ * Where the hosts file lives.
132
+ *
133
+ * `platform` and `env` are injected rather than read, because the Windows leg
134
+ * has to be testable from the macOS and Linux runners in the matrix — there is
135
+ * no second machine to check it on, and a leg nobody can run is a leg nobody
136
+ * has checked.
137
+ *
138
+ * THE THREE SPELLINGS OF SystemRoot. Windows' own environment is
139
+ * case-insensitive, so on a real Windows box any one of these answers. A plain
140
+ * object handed in by a test — or by a caller building an environment for a
141
+ * child process — is a normal JavaScript object and is not, which is how the
142
+ * variable that is always set in production reads as missing in a test.
143
+ * exec.mjs reads two spellings for this reason; the third costs nothing.
144
+ *
145
+ * WHY THE FALLBACK NAMES A DRIVE. With no root at all, joining would produce
146
+ * `\System32\drivers\etc\hosts` — a path that is rooted but drive-less, which
147
+ * Windows resolves against the CURRENT drive. That is a different file on every
148
+ * drive the deck might be started from, and reading the wrong file here means
149
+ * reporting "not blocked" for a machine that is blocked. `C:\Windows` is a
150
+ * guess, but it is a stated one and it is right on approximately every Windows
151
+ * install; a silently drive-relative path is wrong in a way nobody can see.
152
+ */
153
+ export function hostsPath(platform = process.platform, env = process.env) {
154
+ if (platform !== "win32") return "/etc/hosts";
155
+ const e = env || {};
156
+ const root = String(e.SystemRoot || e.systemroot || e.SYSTEMROOT || "").trim();
157
+ return winPath.join(root || "C:\\Windows", "System32", "drivers", "etc", "hosts");
158
+ }
159
+
160
+ /**
161
+ * One line's mapping, or null if the line carries none.
162
+ *
163
+ * The hosts format has no quoting: everything from the first `#` is a comment,
164
+ * the rest is an address followed by one or more names. So a line that is
165
+ * entirely a comment loses its whole body here and answers null — which is what
166
+ * keeps a commented-out killswitch, and a line of prose that merely mentions
167
+ * the host, out of every list this module returns.
168
+ */
169
+ function mapping(line) {
170
+ const body = line.split("#")[0];
171
+ const fields = body.trim().split(/[ \t]+/).filter(Boolean);
172
+ if (fields.length < 2) return null;
173
+ return { address: fields[0], names: fields.slice(1) };
174
+ }
175
+
176
+ /**
177
+ * What a hosts file says about the relay. Takes the TEXT; the caller does the
178
+ * reading, so this stays a pure function and the module stays incapable of
179
+ * touching the file at all.
180
+ *
181
+ * ours lines this feature wrote — the tagged form, exactly. These are the
182
+ * lines the unblock command is allowed to delete.
183
+ * foreign every OTHER live mapping of the host: an untagged black-hole
184
+ * somebody added by hand, a corporate mapping pushed by config
185
+ * management, the host riding along as an alias on another line.
186
+ * Never deleted, always surfaced.
187
+ * blocked whether the name actually fails to resolve to anything reachable.
188
+ *
189
+ * WHY `blocked` IS NOT `ours.length > 0`. A hosts file resolves on the first
190
+ * matching entry, so one line reading `10.4.0.9 bridge.claudeusercontent.com`
191
+ * above our own defeats the block completely while leaving our line right
192
+ * there in the file. Reporting "protected" in that state is the single worst
193
+ * thing a panel like this can do, so any live mapping pointing somewhere
194
+ * reachable takes the claim away — and `foreign` is what tells the user which
195
+ * line to go look at. The mirror case is generous in the safe direction: an
196
+ * untagged `0.0.0.0` block that somebody else installed is a real block, and it
197
+ * counts, even though it is not ours to remove.
198
+ *
199
+ * Host comparison is case-insensitive because DNS is, and because a hosts file
200
+ * with `Bridge.ClaudeUserContent.com` in it resolves exactly the same way. That
201
+ * generosity applies to `foreign` only — see OURS for why the other list stays
202
+ * byte-exact.
203
+ *
204
+ * A non-string answers the empty verdict rather than throwing: the caller's
205
+ * read can fail (no such file on a stripped container, EACCES under a hardened
206
+ * profile) and a panel that cannot see the file has nothing to report, which is
207
+ * not the same as a crash.
208
+ */
209
+ export function readKillswitch(text) {
210
+ const ours = [];
211
+ const foreign = [];
212
+ if (typeof text !== "string") return { blocked: false, ours, foreign };
213
+ let sinkholed = false;
214
+ let reachable = false;
215
+ // Every line ending, including the lone CR nothing writes any more and the
216
+ // CRLF every Windows hosts file uses. Splitting on all three is what keeps a
217
+ // trailing \r out of the strings handed back — a `\r` clinging to the end of
218
+ // a line would defeat the `$` anchor and quietly make a real killswitch read
219
+ // as foreign on the one platform where the file is always CRLF.
220
+ for (const line of text.split(/\r\n|\r|\n/)) {
221
+ const m = mapping(line);
222
+ if (!m) continue;
223
+ if (!m.names.some(n => n.toLowerCase() === RELAY_HOST)) continue;
224
+ if (OURS.test(line)) ours.push(line);
225
+ else foreign.push(line);
226
+ if (BLACK_HOLE.has(m.address.toLowerCase())) sinkholed = true;
227
+ else reachable = true;
228
+ }
229
+ return { blocked: sinkholed && !reachable, ours, foreign };
230
+ }
231
+
232
+ /** macOS. `dscacheutil` empties the cache and the SIGHUP restarts the resolver
233
+ * that holds the rest of it; neither alone is enough, and both have been the
234
+ * documented pair for long enough to survive an OS release. The signal needs
235
+ * root — mDNSResponder is not the user's process — and by this point in the
236
+ * paste sudo has already been answered once, so it costs no second prompt. */
237
+ const FLUSH_DARWIN = "dscacheutil -flushcache 2>/dev/null; " +
238
+ "sudo killall -HUP mDNSResponder 2>/dev/null || true";
239
+
240
+ /** Linux. `resolvectl` only exists where systemd-resolved does, and plenty of
241
+ * distributions run something else or nothing at all — so it is probed for
242
+ * rather than attempted, and the `|| true` means the whole paste still exits
243
+ * 0 on a machine that has no such tool. A missing flush is a cache that
244
+ * expires on its own in a few minutes; a failed paste is a user who thinks
245
+ * the block did not happen and does it again. */
246
+ const FLUSH_LINUX = "command -v resolvectl >/dev/null 2>&1 && " +
247
+ "sudo resolvectl flush-caches 2>/dev/null || true";
248
+
249
+ /** Windows. Needs no elevation of its own, and runs last so its exit status is
250
+ * the only one the user sees. */
251
+ const FLUSH_WINDOWS = "ipconfig /flushdns";
252
+
253
+ /**
254
+ * The command that flips the killswitch. `on: true` blocks the relay, `on:
255
+ * false` lifts the block.
256
+ *
257
+ * IT IS A STRING. The deck prints it, the user reads it, the user pastes it.
258
+ * Nothing here runs it — see the file header for why that boundary is the whole
259
+ * design and not a limitation waiting to be lifted.
260
+ *
261
+ * THE LEADING NEWLINE. The block command tests the last byte of the file before
262
+ * appending, and appends a newline first if the file does not end in one.
263
+ * Without that test, a hosts file whose last line has no terminator — which is
264
+ * a perfectly ordinary file, and what several config-management tools leave
265
+ * behind — gets our text welded onto the end of that last line, producing one
266
+ * corrupt entry out of two valid ones. `$(…)` strips trailing newlines, so the
267
+ * substitution is empty exactly when the file already ends in one; nothing else
268
+ * is being tested there.
269
+ *
270
+ * ONE `sudo`, NOT THREE. The append is two writes and a read of the same file,
271
+ * wrapped in a single `sh -c` so the user is asked for a password once and can
272
+ * read the whole of what is about to run as root in one place. The flush is
273
+ * separated by `;` rather than `&&` for the same reason a missing flush tool is
274
+ * probed for: a cache that would not clear must not take the block down with
275
+ * it.
276
+ *
277
+ * WHY THE UNBLOCK USES sed AND WHY THE FLAG DIFFERS PER PLATFORM. `sed -i` is
278
+ * the readable form, and a command a person is about to run as root earns
279
+ * readability. BSD sed requires an explicit backup suffix and GNU sed forbids
280
+ * one, so the two spellings are not interchangeable — which is exactly what the
281
+ * `platform` parameter is for. The `else` leg here is GNU-shaped, so a BSD that
282
+ * is neither Darwin nor Linux would need its own; ccdeck states support for
283
+ * Linux, macOS and Windows, and this is the edge of that statement rather than
284
+ * an oversight.
285
+ *
286
+ * NOT IDEMPOTENT, ON PURPOSE. Pasting the block twice writes the line twice.
287
+ * That resolves identically, and the unblock deletes every matching line, so
288
+ * the state heals itself — cheaper than a `grep -q` guard that would double the
289
+ * length of a command whose readability is the point.
290
+ */
291
+ export function killswitchCommand(platform = process.platform, { on }) {
292
+ if (typeof on !== "boolean") {
293
+ // Not a defensive nicety. The two commands are opposites, and a caller that
294
+ // forgot the field would otherwise get whichever one the default happened
295
+ // to name — silently lifting a block the user asked to install.
296
+ throw new TypeError("killswitchCommand needs { on: true } or { on: false }");
297
+ }
298
+ if (platform === "win32") return windowsCommand(on);
299
+ const file = hostsPath(platform);
300
+ const flush = platform === "darwin" ? FLUSH_DARWIN : FLUSH_LINUX;
301
+ // BSD sed wants the backup suffix as its own argument and reads an empty one
302
+ // as "no backup"; GNU sed reads a following argument as the script.
303
+ const inPlace = platform === "darwin" ? "sed -i ''" : "sed -i";
304
+ // ALWAYS A LEADING NEWLINE, WHICH IS WHY THIS IS ONE SHORT LINE.
305
+ //
306
+ // The first version tested whether the file already ended in one — `[ -n
307
+ // "$(tail -c1 …)" ] && printf …` inside a `sudo sh -c '…'` with nested quotes
308
+ // — because appending to a hosts file with no trailing newline glues the new
309
+ // entry onto its last line and corrupts it. Correct, and three lines of
310
+ // shell that a person is asked to read before running as root, which is
311
+ // exactly the wrong place to spend somebody's attention.
312
+ //
313
+ // A leading `\n` is correct in BOTH cases and needs no test: a file that ends
314
+ // in a newline gains a blank line, which every hosts parser ignores, and one
315
+ // that does not gets its last line finished. `tee -a` also drops the nested
316
+ // quoting entirely, since only `tee` needs to be root — the printf runs as
317
+ // the user and the pipe carries the text.
318
+ const command = on
319
+ ? `printf '\\n%s\\n' "${KILLSWITCH_LINE}" | sudo tee -a ${file} >/dev/null\n${flush}`
320
+ : `sudo ${inPlace} '/${TAGGED_BRE}/d' ${file}\n${flush}`;
321
+ return { command, needsAdmin: true, note: note(platform, on) };
322
+ }
323
+
324
+ /**
325
+ * The Windows pair, as PowerShell.
326
+ *
327
+ * `$env:SystemRoot` rather than the path this process resolved: the command
328
+ * runs in somebody else's elevated shell, and that shell knows where Windows is
329
+ * installed without being told by us. It also keeps this process's environment
330
+ * out of a string that is about to run as Administrator.
331
+ *
332
+ * Read-modify-write through `[IO.File]` rather than `Add-Content`, because
333
+ * Add-Content appends a terminator AFTER its value and never one before it —
334
+ * which is precisely the missing-trailing-newline corruption, just spelled in
335
+ * PowerShell. Writing the whole text back keeps the file's own ACL: the handle
336
+ * truncates a file that already exists rather than creating a new one, so the
337
+ * inherited permissions on a file in System32 are not quietly replaced by
338
+ * whatever the elevated shell would have created.
339
+ *
340
+ * `-cnotmatch`, not `-notmatch`. PowerShell's comparison operators are
341
+ * case-INSENSITIVE by default, which would let the delete take a line the
342
+ * `ours` matcher above refuses to claim — a command that removes more than the
343
+ * module says it will is the exact failure this feature exists to not repeat.
344
+ * `@(…)` forces an array so that a hosts file reduced to a single line does not
345
+ * arrive at WriteAllLines as a bare string.
346
+ */
347
+ function windowsCommand(on) {
348
+ const file = "$h = $env:SystemRoot + \"\\System32\\drivers\\etc\\hosts\"";
349
+ const command = on
350
+ ? [
351
+ file,
352
+ "$t = [IO.File]::ReadAllText($h)",
353
+ "if ($t.Length -gt 0 -and -not $t.EndsWith(\"`n\")) { $t += \"`r`n\" }",
354
+ `$t += "${KILLSWITCH_LINE}\`r\`n"`,
355
+ "[IO.File]::WriteAllText($h, $t)",
356
+ FLUSH_WINDOWS,
357
+ ].join("; ")
358
+ : [
359
+ file,
360
+ `$p = '${TAGGED_PATTERN}'`,
361
+ "[IO.File]::WriteAllLines($h, @(Get-Content -LiteralPath $h | " +
362
+ "Where-Object { $_ -cnotmatch $p }))",
363
+ FLUSH_WINDOWS,
364
+ ].join("; ");
365
+ return { command, needsAdmin: true, note: note("win32", on) };
366
+ }
367
+
368
+ /**
369
+ * What the command does not do, said in the panel rather than discovered later.
370
+ *
371
+ * The sentence about existing connections is the one that matters and it is
372
+ * deliberately not written any stronger than what was actually observed: a
373
+ * hosts entry is consulted when a name is resolved, and a socket that is
374
+ * already open was resolved before the entry existed. It stays up. What ends it
375
+ * is the browser restarting — not this command, and not waiting.
376
+ */
377
+ function note(platform, on) {
378
+ const paste = platform === "win32"
379
+ ? "Run it in a PowerShell started as Administrator."
380
+ : "Paste it in a terminal yourself.";
381
+ // Named for what the platform actually shows, because the promise is about a
382
+ // dialog the user might otherwise see with ccdeck's name on it.
383
+ const never = platform === "win32"
384
+ ? "ccdeck never runs it and never raises a UAC prompt."
385
+ : "ccdeck never runs it and never asks for your password.";
386
+ const survives = "Blocking the name stops new connections to the relay; " +
387
+ "it does not close one the extension already holds, and that one lasts " +
388
+ "until the browser restarts.";
389
+ const surgical = `Removes only the line tagged "${TAG}" — any other mapping ` +
390
+ `of ${RELAY_HOST} in the file is left exactly where it is.`;
391
+ const flush = platform === "linux"
392
+ ? " The DNS cache flush runs only where systemd-resolved is installed and " +
393
+ "is skipped, not failed, everywhere else."
394
+ : "";
395
+ return `${paste} ${never} ${on ? survives : surgical}${flush}`;
396
+ }
397
+
398
+ /**
399
+ * The API surface worth reporting, in the order it earns alarm.
400
+ *
401
+ * Every one of these was granted at the BROWSER level, and that is the fact to
402
+ * hold on to: `debugger` is the Chrome DevTools Protocol over every tab, which
403
+ * is read-anything and click-anything; `nativeMessaging` reaches a program
404
+ * outside the sandbox; `downloads` writes files to disk; `tabs` and `scripting`
405
+ * are the enumerate-and-inject pair.
406
+ */
407
+ const SENSITIVE_APIS = ["debugger", "nativeMessaging", "downloads", "tabs", "scripting"];
408
+
409
+ // Host patterns that mean every site there is. `<all_urls>` is what the real
410
+ // profile on this machine carries; the any-scheme wildcard below it is the
411
+ // other spelling Chrome accepts for the same reach, and missing it would
412
+ // under-report the one thing this field exists to report. A scheme-specific
413
+ // wildcard — every https site, say — is deliberately NOT counted: it is broad,
414
+ // but it is not every site, and this flag should mean what it says.
415
+ //
416
+ // Written as line comments rather than a block, because the pattern itself
417
+ // contains the sequence that closes one.
418
+ const EVERY_SITE = new Set(["<all_urls>", "*://*/*"]);
419
+
420
+ /** Defensive array read. A "Secure Preferences" file is Chrome's to write and
421
+ * ours only to read; a field that is a string where an array was expected is a
422
+ * Chrome release note we have not seen yet, not a reason to throw inside a
423
+ * panel. */
424
+ const list = v => (Array.isArray(v) ? v.filter(x => typeof x === "string") : []);
425
+
426
+ /**
427
+ * What one browser profile granted the extension. Takes the already-parsed
428
+ * "Secure Preferences" object, so the file reading — and its JSON.parse, which
429
+ * throws on a profile Chrome is mid-write on — stays with the caller.
430
+ *
431
+ * present the extension has an entry in this profile.
432
+ * enabled and it is not switched off.
433
+ * allUrls it may act on every site.
434
+ * sensitiveApis which of the APIs above it holds, in SENSITIVE_APIS order
435
+ * rather than the file's, so the panel renders the same list
436
+ * twice in a row and a test can assert on it.
437
+ *
438
+ * WHY `disable_reasons` AND NOT `state`. Checked against a real profile on this
439
+ * machine: the entry carries `disable_reasons: []` and no `state` key at all.
440
+ * A non-empty array is Chrome saying why it turned the extension off, so an
441
+ * empty one is "no reason to be off" — enabled. The direction is worth stating
442
+ * because it is the good news in this whole module, and a report that shows a
443
+ * disabled extension as a live threat is a report the user stops reading.
444
+ *
445
+ * WHY REMOVING SITE PERMISSIONS IN chrome://extensions DOES NOT HELP. These
446
+ * permissions are held at the browser level — the same profile shows
447
+ * `withholding_permissions: false` and `<all_urls>` in both `explicit_host` and
448
+ * `scriptable_host` — and the per-site allowlist a user configures is enforced
449
+ * INSIDE the extension, by the extension. Tightening it narrows what the
450
+ * extension chooses to do, not what it is able to do, and an operator driving
451
+ * it through the relay is not bound by the extension's own UI. So this field
452
+ * reports the browser-level grant, which is the one that would still be true
453
+ * after a user "fixed" it in the settings page.
454
+ *
455
+ * `scriptable_host` is read alongside `explicit_host` for the same
456
+ * under-reporting reason as the any-scheme wildcard in EVERY_SITE:
457
+ * content-script reach into every page is the same exposure arriving through a
458
+ * different key.
459
+ */
460
+ export function extensionReport(securePreferences, extId = CLAUDE_EXT_ID) {
461
+ const settings = securePreferences?.extensions?.settings;
462
+ const has = settings && typeof settings === "object" &&
463
+ typeof extId === "string" && Object.hasOwn(settings, extId);
464
+ // Object.hasOwn rather than a plain lookup: `settings["constructor"]` is a
465
+ // function on every object alive and `settings["__proto__"]` is a prototype,
466
+ // and either one would sail past a truthiness check and be reported as an
467
+ // installed extension. The id is a caller-supplied string; see
468
+ // prototype-keys-474.test.ts for the same footgun caught elsewhere here.
469
+ const entry = has ? settings[extId] : null;
470
+ if (!entry || typeof entry !== "object") {
471
+ return { present: false, enabled: false, allUrls: false, sensitiveApis: [] };
472
+ }
473
+ const granted = entry.granted_permissions;
474
+ const hosts = [...list(granted?.explicit_host), ...list(granted?.scriptable_host)];
475
+ const api = new Set(list(granted?.api));
476
+ return {
477
+ present: true,
478
+ enabled: !(Array.isArray(entry.disable_reasons) && entry.disable_reasons.length > 0),
479
+ allUrls: hosts.some(h => EVERY_SITE.has(h)),
480
+ sensitiveApis: SENSITIVE_APIS.filter(name => api.has(name)),
481
+ };
482
+ }
483
+
484
+ /**
485
+ * The headline, from the two facts that decide it.
486
+ *
487
+ * `anyExtension` is the caller's aggregate across every profile of every
488
+ * browser it found — one enabled copy anywhere is enough, because the relay is
489
+ * per-account and not per-profile. With no extension installed there is nothing
490
+ * to block and nothing to warn about, so a machine with no browser extension
491
+ * and no hosts entry is "nothing-exposed" rather than "exposed": the killswitch
492
+ * is not a thing this user has to do.
493
+ *
494
+ * Deliberately three states and not two. "protected" and "nothing-exposed" both
495
+ * mean "no action needed" today, and collapsing them would make the panel say
496
+ * the block is working on a machine where it was never needed — which is the
497
+ * kind of reassurance that stops meaning anything.
498
+ */
499
+ export function verdict({ anyExtension, blocked } = {}) {
500
+ if (!anyExtension) return "nothing-exposed";
501
+ return blocked ? "protected" : "exposed";
502
+ }