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.
- package/README.md +6 -2
- package/dist/web/assets/index-6cAJDiBs.js +125 -0
- package/dist/web/assets/index-Bnn7d7u8.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/src/server/agent-activity.mjs +506 -0
- package/src/server/browser-history.mjs +467 -0
- package/src/server/browser-presence.mjs +157 -0
- package/src/server/browser-profiles.mjs +267 -0
- package/src/server/browser-react.mjs +204 -0
- package/src/server/browser-watch-store.mjs +288 -0
- package/src/server/browser-watch.mjs +717 -0
- package/src/server/claude-accounts.mjs +77 -7
- package/src/server/codex-usage.mjs +79 -1
- package/src/server/cswap-admin.mjs +485 -36
- package/src/server/index.mjs +172 -4
- package/src/server/installer.mjs +13 -0
- package/src/server/relay-guard.mjs +502 -0
- package/src/server/system-metrics.mjs +715 -32
- package/dist/web/assets/index-BgcITzCV.css +0 -1
- package/dist/web/assets/index-ChVeFKhn.js +0 -101
|
@@ -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
|
+
}
|