Reverse-engineering GoGuardian: what the school Chrome extension actually does
20 permissions, one request every 60 seconds, & a Windows program that can lock the screen
Today I decided to start reversing GoGuardian. Not the company's server code, which nobody has; the thing that actually sits on the student's machine. That thing is a Chrome extension, version 4.1.246 at the start of the day, one 3.7 MB bundle of JavaScript plus 22 content scripts that run on every page of every website.
I unpacked it, ran a beautifier over 31 files, & read all of it. Reading told me what the code intends to do. The route map, 30 backend calls across 9 services, was sitting in plain text in one code block1. Code is a plan, not a transcript, so I also built a network path the extension can't skip & watched it talk for a day: 2,800+ requests captured, 11 of those 30 routes caught in the act with full request & response bodies.
What GoGuardian says it sells
GoGuardian markets itself as classroom software. Their own homepage, read today, makes these claims:
“The #1 choice for K-12 web filtering on any OS”
“The most loved classroom management tool”
“Protect students from self-harm and violence”
“GoGuardian helps over 70% of top districts create safer schools, empowered teachers, and thriving students”
“Rooted in research and built with privacy at the core”
goguardian.com, fetched November 5, 20262
The scale numbers on the same page: 25 million students, 2 million educators, 2.4 billion browsing events processed every day, 689 million web pages scanned every day. Filtering plus classroom management plus student safety. That's the pitch.
The manifest tells a different kind of truth. It's the spec sheet the
browser shows before install, & it lists 20 permissions with
optional_permissions: null. Nothing here is a request you can
decline3.
The permissions, in plain English
| Permission | What it lets the extension do |
|---|---|
webRequest + webRequestBlocking | See every network request before it leaves the browser, & stop or redirect it. This one pair is the web filter itself. |
tabs | Read the address & title of every open tab, close tabs, navigate tabs. |
webNavigation | See every page load attempt, including the ones that fail. |
scripting | Inject JavaScript into any page it can see. |
management | See & control every other extension in the browser. That includes disabling your ad blocker or uninstalling it. |
nativeMessaging | Talk to a program installed on Windows, outside the browser. More on this below. |
identity + identity.email | Read who is signed into Chrome & that account's email address. |
enterprise.deviceAttributes | Read the machine's serial number & asset tag, the fields the school's IT policy writes into Chrome. |
system.cpu + system.memory | Read CPU & RAM usage of the laptop. |
idle | Know when nobody has touched the machine. |
alarms | Set timers that wake the background worker. This is the 60-second heartbeat. |
storage + unlimitedStorage | Keep state & screenshots with no size cap. |
offscreen | Run a hidden page for work the background worker can't do. |
notifications | Pop notifications on the desktop. |
background, activeTab | Keep running after all tabs close; see the current tab on demand. |
Read the top four rows together. See every request, stop any request, read
every tab, inject code into any page. That combination is what a filter needs
& it is also total control of what loads. The extension runs on
<all_urls>, so the answer to “which sites” is
all of them.
Other notable info: it sends your browsing every minute
Every 60 seconds the extension POSTs a batch to
prod-zephyr.goguardian.com/v1/entities. The net request
schema4 is plain JSON: the URL, the page
title, the favicon, the tab id, when you started looking at it & when you
stopped, plus the site's category number. Captured live, not inferred from
code. The response is {}; nobody's reading it in real time, it's
a log.
The 60-second batch is the spine. Around it sit four more streams, all observed in a single day of test browsing:
| What | Where it goes | How often |
|---|---|---|
| Every URL you visit, with a category number attached | panther.goguardian.com/api/v2/categories | On every navigation |
| The full visible text of the page & text pulled out of each HTML tag | prod-go-x3-predictor.goguardian.com/v1/predict | When page text changes |
| Every AI prompt & every AI reply, both halves | ai-events-intake.goguardian.com/v1/submit | Per message |
| “Is a class in session right now?” | inquisition.goguardian.com/v3/ext/livemode | Every 10 seconds |
The page-text one deserves a sentence of its own. On a Google Classroom page, the classifier request5 contained every classmate's name & school email address, because they were on the screen & the schema takes what's visible. GoGuardian's own marketing says “2.4 billion browsing events processed every day”. Now you know what an event is made of.
The AI capture6 is the same idea: I typed
“5+5” into Gemini, & the extension submitted my prompt as
TYPE_USER_PROMPT & the model's answer as
TYPE_MODEL_RESPONSE, 2 to 14 seconds apart, sharing a session
id. The server replied with a category (CATEGORY_APPROPRIATE,
CATEGORY_UNCATEGORIZED) which got folded into the next
minute's browsing batch. Prompt text, category & the page you were on
arrive as one joined record.
Two smaller finds. The teacher-set bypass password for blocked sites travels
as a plain query parameter,
GET /v1/admin/validate-bypass-password?password=1111. And every
request carries one shared login token whose claims decode to your school's
org id, the admin id, your account id & a device id7,
valid 24 hours, sent on every call to every service.
What happens the moment you open a new tab
Opening a tab looks like one action. It's a chain of listeners firing in
order, & the first two run before a single byte leaves the machine:
tabs.onCreated re-runs the filter against the URL Chrome is about
to load, then webRequest.onBeforeRequest grabs every main frame,
sub frame & embedded object request on <all_urls> &
holds it8. The filter gets 500 milliseconds to
answer. If the site's category isn't cached, it fires a Panther lookup first
& Chrome sits there waiting for the answer.
“Block” produces two responses, not one: a redirect to
blocked.goguardian.com handed straight back to Chrome so the
request never leaves the machine, & a backup chrome.tabs.update
that retries five times, one second apart, if the first route failed. Then
tabs.onUpdated runs the same decision again on the committed URL.
After that, a health check re-injects any missing content script every 25
seconds for the life of the tab, because the code found a way to lose them:
press escape while a page is loading & Chrome aborts the injection. The log
line calls it the “ESC key exploit”.
I watched the same chain from the outside: typed “games” into the address bar, hit enter, clicked the first result. The wire did this9:
-5.5s address-bar suggestions start GET google.com/complete/s?client=chrome-omni
-3.5s category lookup, 0.1s before the page commits
POST panther…/api/v2/categories {"cleanUrl":"google.com/search?q=games",…}
-3.4s the search results page loads GET www.google.com/search?q=games 200
-3.1s the 60-second batch files the tab you came from
POST prod-zephyr…/v1/entities entity: chrome://newtab/, title "New Tab"
-1.1s you click a result GET www.google.com/goto?url=… 302
-0.7s the destination gets a category POST panther… {"cleanUrl":"crazygames.com/"}
+0.0s the block page GET blocked.goguardian.com/?ctx=… 200
Two numbers in there are worth stopping on. The destination was categorized 0.7 seconds before the block page appeared, which is the filter working as designed. The new tab you came from got filed as a browsing entity with a title & category numbers attached, a page that never touched the network. Nothing with a URL is invisible, not even the blank page you started on.
There's a second consumer of that same pipeline. When the classifier's
confidence crosses the medium threshold, the extension files a smart-alerts
report carrying the page's full text & gets a report id back9,
which is how a blocked page about games ended up in an admin queue as
CATEGORY_GAME, CONFIDENCE_VERY_HIGH, rawProbability: 1.
The iframe hole is a switch, not a bug
Chromebook & Windows run the same filter code, byte for byte, so when a blocked site loads fine inside an iframe on one machine & not on the other, the difference can't be the OS. It's one line of JavaScript10:
const c = l.safeMode?.enabled || !1,
h = l.orgSettings?.blockSubFrames || !1;
if ("sub_frame" === t && (c || !h)) return;
Read it plainly: sub-frame requests skip the entire filter when the org's
blockSubFrames is off, or when safe mode is on. When it's on, a
blocked iframe gets redirected twice over, once by Chrome for the frame itself
& once by the content script inside it, which navigates with
window.location.href = n.url. Which side of that line a school is
on is an admin panel setting. The OS doesn't get a vote.
I tested this district's policy directly: a page with an iframe pointing at a known-blocked game site loaded the frame with HTTP 200 & no GoGuardian redirect anywhere in the trace. The “refused to connect” error that showed up was the game site declining to be framed, a normal header any site can set, not a filter. So on this policy, any site that lets itself be framed loads unfiltered inside a frame while the same URL is blocked at the top level. Marketing says “on any OS”. The code says “if the admin flipped the switch”.
The Windows side: a second app, outside Chrome
The extension isn't only JavaScript. On Windows it connects to a native
host named chromium.goguardian.ggwindows11,
a separate GoGuardian program installed on the machine that Chrome pipes JSON
to. I have the extension's half of that conversation, which is fully readable;
the .exe itself is a different binary I don't have.
The protocol is 7 message types over a fixed wire format12:
each request is {id, type, payload}, each reply echoes the id.
Three second timeout by default, 35 seconds for closing an app.
| Command | What it does | Who triggers it |
|---|---|---|
ping | Is the Windows program alive | On connect |
config | Hands the program the session token as Windows-Native-Host-Bearer-<token> plus a metrics host | On connect & whenever the session changes |
login | Returns the Windows user's email, full name, org directory & a device id | Identity check |
screenshot | Returns a picture of the whole desktop, not just the tab | Teacher screenshot feature |
list_apps | Returns the list of running processes | Refreshed on a timer during class |
close_apps | Kills a process by its process id | Teacher closes an app13 |
lock_screen | Locks or unlocks the Windows screen with a message the teacher typed | Server pushes teacher state13 |
Notice who's on the other end of the last three: a teacher's dashboard,
routed through the server. The lock message comes straight out of the teacher
state payload, setLockState("lock", e.teacher.shadow?.lockMessage).
The server says lock, the laptop locks, & the message on the lock screen is
whatever the teacher typed.
On my test machine, none of it was installed. The registry key Chrome looks
up before it can even start a native conversation,
HKLM\SOFTWARE\Google\Chrome\NativeMessagingHosts\chromium.goguardian.ggwindows,
doesn't exist in any of the three places Chrome checks, no process matches,
& there's no folder in Program Files14.
The only native hosts registered on that box are Microsoft's two. So all seven
commands are theater there: the extension asks, nobody answers, & the login
token shows the consequence, a device id that fell back to
self-generated:<uuid> because there was no Windows program
to supply a real one. Screenshot, app killer, screen locker: dormant until the
district installs a second program. The filter never needed it.
Does the school get to run code on your machine?
No, not in this version, & I checked properly. If GoGuardian's servers could push a string of JavaScript to the extension & have it executed, then anyone controlling (or compromising) those servers would have code execution on every managed laptop in the country. That's the arbitrary-code-execution question, sometimes called an invokeJS-style channel. So I went looking for it across all 31 files:
eval(: 0 matches. Not one.new Function(...): only lodash's boilerplatereturn this || new Function("return this"), which never touches server data.chrome.scripting.executeScript: the wrapper exists, but every call site passes a hardcoded function or a fixed file name. The most dynamic one in the whole bundle isfunc: () => self.origin15.
So the server's reach stops at the enumerated list: lock the screen, kill a process, take a screenshot, read the page, block a URL, fetch settings. If you know the client, you know the limits of the server, because the client is the thing that decides what commands exist. There's no “run this” verb in the vocabulary. What they do with the six verbs they do have is a policy question, not a technical one.
One caveat I won't pretend away: the files I read say 4.1.246, & every one of the 1,414 GoGuardian requests in the capture says 4.1.247. The swap happened between those two points, with nobody asking Chrome to do it. Every release needs the same read, because the limits of the server move when the client does.
You can't delete it, & patching it is worse
The extension shows up with three layers of comeback16.
The manifest declares update_url: https://ext.goguardian.com/stable.xml,
so Chrome holds a standing order to check GoGuardian's own server for new
versions. The background worker watches latestShieldVersion from
the settings endpoint & calls Chrome's update check the moment it differs
from its own version; a major version bump reloads the whole extension on a
10-second timer, & a same-major bump waits for the teacher's session to end
first if one is running. And every 25 seconds a health check re-injects any
content script that went missing.
I didn't have to stage anything to watch layer two announce itself: the
settings endpoint handed down "latestShieldVersion": "4.1.247"
while the files in front of me said "version": "4.1.246", &
by the time the capture started every request already carried
extension-version: 4.1.247. Chrome replaced the folder on its
own, on GoGuardian's schedule, & told nobody.
Which mechanism restores a deleted folder depends on how the extension got
installed, but the outcome is the same either way: the files in the profile
are a copy Chrome made from bytes it verified, & Chrome is good at putting
them back. Editing one by hand only lasts until the next reload or update.
There's also a tattletale: chrome.management.getAll() reports
which other extensions are installed to the server on the next sync, so
disabling the ad blocker or the GoGuardian-blocker next to it is itself an
event. The copy Chrome runs is the copy Chrome controls.
How I watched it talk
A proxy is a suggestion; code that ignores it goes straight out. So the test machine, a Windows 10 VM, got a virtual network card plugged into a host-only network, & the host rewrote every outgoing TCP 80 & 443 packet to detour through a decoder before it could leave. DNS went to a local resolver, & UDP 443 got dropped outright so Chrome couldn't sneak encrypted traffic past via QUIC. The guest's proxy settings were empty. It believes it's talking directly to the internet.
It has no certificate pinning, so a user-trusted certificate was enough to decrypt everything. 2,800+ flows later, every GoGuardian endpoint turned out to be plain JSON over ordinary HTTP, which is why the schemas in this post are quotes rather than guesses.
How much of it is actually known
Static reading found 30 routes across 9 services. Live capture verified 11 of them with real bytes. The split matters more than the ratio: six of the nine services are covered end to end, because they fire on ordinary browsing. What's missing is the half that only a real classroom triggers.
| Service | Routes seen live | Why |
|---|---|---|
| Zephyr, Panther, CountVonCount, GoX3, Inquisition, AI events | 7 of 7 | they fire when anyone browses |
| ExtensionGateway (settings, policy, chat, calls, lesson plans) | 4 of 18 | needs a teacher in a live session |
| Screenshots service | 0 of 3 | needs the screenshot cadence |
| Beacon full-page predictor | 0 of 1 | needs flagged content |
No teacher was driving a class during the capture, so the live-session poll returned an empty list all day & zero WebSocket frames were seen. Those schemas stay inferred from code until somebody sits through a real period with the dashboard open.
Next post: ways to interfere with all of this
This post was read-only: what the thing is, what it sends, what it can be asked to do. The next one flips it around. No certificate pinning, plain JSON, exact host names, exact intervals, a token that's valid 24 hours & a protocol with 7 known verbs make a short list of ways to interfere with the pipeline from the network side & from the client side, what each one costs you, & what a school district sees when it happens. Same extension, same capture file, opposite direction.
Sources
Every claim above links to a footnote. Captured quotes are verbatim from
/tmp/gg-capture.mitm; code quotes are line-numbered from the
beautified bundle in work/4.1.246_0/.
1. The route map: 30 routes, 9 services, one code block
background.js.pretty:45388-45525 (excerpt)
this.WriteEntitySet = this.wrapRoute(k.Zephyr, v.WriteEntitySet, {
sessionType: "identified",
retry: { maxRetries: 3 }
}), this.GetCategoriesForURL = this.wrapRoute(k.Panther,
b.GetCategoriesForURLV2, …
Host constants live at
background.js.pretty:53510-53522: prod-ext-gateway,
prod-zephyr, panther, screenshots,
beacon-fullpage-predictor, inquisition,
whiteboard, prod-go-x3-predictor,
countvoncount, ai-events-intake.
2. GoGuardian marketing copy (goguardian.com, fetched 2026-11-05)
Products page:
Admin "The #1 choice for K-12 web filtering on any OS"
Teacher "The most loved classroom management tool"
Beacon "Protect students from self-harm and violence"
Hero:
"#1 for outcomes in student safety and productivity"
"GoGuardian helps over 70% of top districts create safer schools,
empowered teachers, and thriving students with AI-driven innovation."
"25M+ Supporting 50% of US K-12 students"
"2M+ educators trusting GoGuardian"
"2.4B+ browsing events processed every day"
Scale block:
"Each day GoGuardian:
Processes 2.4B+ browsing events
Scans 689M web pages
Manages 800K classroom sessions"
Footer: "(c) 2026 Liminex, Inc. doing business as GoGuardian."
Quoted from the homepage text as served on 2026-11-05; product-tile lines run together in their HTML, so each line above is one tile.
3. manifest.json: the 20 permissions, none optional
og-backup/4.1.246_0/manifest.json
"name": "GoGuardian", "version": "4.1.246"
"optional_permissions": null
"permissions": [
"activeTab", "alarms", "background",
"enterprise.deviceAttributes",
"identity", "identity.email", "idle",
"management", "nativeMessaging", "notifications",
"offscreen", "scripting", "storage",
"system.cpu", "system.memory", "tabs",
"unlimitedStorage", "webNavigation",
"webRequest", "webRequestBlocking"
]
Content script match patterns
include <all_urls> on 14 of the 22 scripts. Extension id:
haldlgldplgnggkjaafhelgiaglafanh.
4. Net request schema: the 60-second browsing upload (captured live)
POST prod-zephyr.goguardian.com/v1/entities (gzip, every 60 s)
{
"submissionTimeMs": "1793916949201",
"unadjustedCurrentTimeMs": "1793916949201",
"extEnvironment": {
"inActiveClassroomSession": false,
"personalDevice": true,
"platform": "WINDOWS"
},
"entities": [{
"header": {
"seqId": "179391683948548807",
"url": "https://classroom.google.com/r/…/sort-last-name",
"faviconUrl": "https://ssl.gstatic.com/classroom/favicon.png",
"title": "People",
"tabId": "51572745",
"containsImageBlock": false,
"inSchool": true
},
"viewings": [{"startTimeMs": 1793916839485,
"endTimeMs": 1793916949200}],
"teacher": null,
"siteCategoryIds": [26],
"flaggedActivity": [], "videos": [], "docs": [], "tabs": [],
"adminPolicyOverride": null, "x3ReportPublicIds": [],
"iframes": [], "aiEvents": []
}]
}
Res: 200 {} (empty object, an ack)
Static ref:
WriteEntitySet, background.js.pretty:45408, route
:45311. The empty-but-present arrays show the schema reserves
slots for videos, docs, iframes & AI events whether or not this session
filled them.
5. Full page text sent to the classifier (captured live)
POST prod-go-x3-predictor.goguardian.com/v1/predict
{
"url": "https://classroom.google.com/r/…/sort-last-name",
"siteCategories": [26],
"fullText": "skip to main content main menu …",
"domContent": {"elements": {
"title": {"values": ["people"]},
"h1": {"values": ["your favorites"]},
"span": {"values": ["account", "gmail", "drive", …]},
"metaContent": {"values": ["******@students.hartfordschools.org", …]},
"div": {"values": [ … ]},
"imgSrc": {"values": ["https://lh3.google.com/u/0/…"]}
}}
}
Res: {"explicitPrediction":{"confidence":"CONFIDENCE_NONE",
"rawProbability":0.00552},
"proxyPrediction":null, "gamePrediction":null}
Static ref:
SmartAlertsPredict, background.js.pretty:45449. The
metaContent array held a student's school email address because
it was visible on the page.
6. Both halves of an AI conversation (captured live)
POST ai-events-intake.goguardian.com/v1/submit
{"data":{
"url":"https://gemini.google.com/app/7b681ae1333c833d",
"system":"SYSTEM_GOOGLE_GEMINI",
"type":"TYPE_USER_PROMPT",
"sessionUuid":"10806ae3-3938-407f-94f1-b308ad98d130",
"sortIndex":3,
"contentParts":[{"text":"dont use code"}],
"capturedAt":"2026-11-05T22:20:27.703Z",
"classroomSessions":[]
}}
Res: {"prediction":{"categories":["CATEGORY_UNCATEGORIZED"]}}
Static ref:
SubmitAIEvent, background.js.pretty:45520. The
reply arrives as TYPE_MODEL_RESPONSE in a second POST sharing
the sessionUuid. Categories seen live:
CATEGORY_APPROPRIATE, CATEGORY_UNCATEGORIZED. Both
kinds also show up later inside the Zephyr batch's aiEvents[].
7. The login token every request carries (decoded)
Header: {"alg":"HS512","typ":"JWT"}
iss "gg:ext-gateway"
exp unix seconds, 24 h from iat
ggoi "22159" GoGuardian org id (the school)
ggai "163804670" admin id
ggsai "7688774114" account id
ggdi "self-generated:<uuid>" device id
ggski "42b42c54-…" session/school key id
Sent as: authorization: Bearer <jwt>
extension-version: 4.1.247
Captured on every one of the 11 verified routes; the same token is handed to the Windows native host as
compRand (source 12).
8. The request path of a new tab (static)
background.js.pretty:47618-47642 (filter registration)
blockingWindowMs: 500
chrome.webRequest.onBeforeRequest.addListener((e => P({
request: e, store: n, rules: L
}).response), {
urls: ["<all_urls>"],
types: ["main_frame", "sub_frame", "object"]
}, ["blocking"]),
chrome.tabs.onCreated.addListener((async e => { await z(e, n) })),
chrome.tabs.onUpdated.addListener((async (e, t, r) => {
(t.url || t.status) && await z(r, n)
}))
background.js.pretty:53653-53671 (health check)
c.I.seconds(25, (async () => { … chrome.tabs.query({status:"complete", …}) … })).start(),
chrome.webNavigation.onErrorOccurred.addListener((async s => {
if ("outermost_frame" === s.frameType && … "net::ERR_ABORTED" === s.error) …
"[lib/content-script-health-check] … health (ESC key exploit)"
The Panther category fetch sits
inside the blocking handler at :47576-47599: an uncategorized URL
delays the response, which delays the navigation. webNavigation
handlers for onBeforeNavigate/onCommitted only run
while session.restricted (:48665-48688).
9. The new-tab timeline & the report it can trigger (captured live)
/tmp/gg-capture.mitm, 17:13:46-17:13:52 (typed "games", clicked first result)
-5.5s GET www.google.com/complete/s?client=chrome-omni… 200
-3.5s POST panther.goguardian.com/api/v2/categories 200
{"cleanUrl":"google.com/search?q=games&oq=games…","rawUrl":…}
-3.4s GET www.google.com/search?q=games 200
-3.1s POST prod-zephyr.goguardian.com/v1/entities 200
entities=[{header:{url:"chrome://newtab/",title:"New Tab"}}]
-1.1s GET www.google.com/goto?url=CAESWAHE… 302
-0.7s POST panther.goguardian.com/api/v2/categories 200
{"cleanUrl":"crazygames.com/","rawUrl":"https://www.crazygames.com/"}
+0.0s GET blocked.goguardian.com/?ctx=YWZpPTk0… 200
Same session, 20:08:32 - the report route:
POST prod-ext-gateway.goguardian.com/v1/admin/smart-alerts/reports
{"url":"data:text/html,…","pageTitle":"data:text/html,…",
"clientTimestamp":"2026-11-06T01:08:31.617Z","sources":["Text"],
"prediction":{"category":"CATEGORY_GAME","confidence":"CONFIDENCE_VERY_HIGH",
"rawProbability":1},
"triggerIds":[],"screenshot":"",
"textMetadata":{"content":"hartford public schools page blocked by school admin …"}}
Res: {"reportId":"4608d230-be5a-44c1-bdad-1147635c0e8d"}
Static ref:
CreateSmartAlertsReport, background.js.pretty:45201,
route :45444 (identified session, 3 retries); the report only
fires at confidence ≥ CONFIDENCE_MEDIUM
(:2123-2130). The timeline anchor t=0 is the block-page request.
10. The sub-frame gate & the live iframe test
background.js.pretty:47550-47551
const { adminSettings: l } = o.value,
c = l.safeMode?.enabled || !1,
h = l.orgSettings?.blockSubFrames || !1;
if ("sub_frame" === t && (c || !h)) return;
goguardian-10.js:4367-4369 (what happens when it doesn't return)
gn.redirect.onRedirectSubFrame((n => {
window.location.href !== n.url && (window.location.href = n.url)
}))
/tmp/gg-capture.mitm, 20:08:30 (iframe src = a known-blocked site)
GET www.crazygames.com/ 200 no redirect, no blocked.goguardian.com hop
Panther still categorized the
data: page hosting the frame ({"cats":[]}) while the
framed site sailed through. The org's actual blockSubFrames value
lives in the /v1/policy payload, which only arrives when the
policy hash changes.
11. Native host connection: chromium.goguardian.ggwindows
background.js.pretty:49941
const e = chrome.runtime.connectNative("chromium.goguardian.ggwindows");
…
u = await Promise.race([i.Uc.windows.ping(e), o]);
return r(), e.onDisconnect.addListener(this.onDisconnect),
await this.sendConfig(e), …
The connection is pinged with
a race against a disconnect handler, then immediately configured with the
session token. Module wrapper at :49934-50061.
12. Native host wire format & the 7 message types
background.js.pretty:54775 (wire format)
sendToWindowsNativeHostWithResponse: (t, n, s = 3e3) => new Promise(…) => {
const o = {
id: `${e}-${randomId()}`,
type: e, // e.g. "ping", "lock_screen"
payload: n
};
… t.postMessage(o) // response must echo the same id & type
}
background.js.pretty:55039-55045 (the 7 types)
const s = (0, r.u)("ping"),
i = (0, r.u)("login"),
a = (0, r.u)("screenshot"),
o = (0, r.u)("list_apps"),
u = (0, r.u)("close_apps"),
l = (0, r.u)("lock_screen"),
c = (0, r.u)("config")
Default timeout 3,000 ms;
close_apps is called with 35e3 (35 s) at
:50026. Config payload at :49966-49971:
{compRand: "Windows-Native-Host-Bearer-<jwt>", metricsHost,
orgID: 0, subAccountId: 0}.
13. Server-driven lock screen & teacher-driven app close
background.js.pretty:53340 (lock is pushed from teacher state)
W = e => c.A.setLockState("lock", e.teacher.shadow?.lockMessage || ""),
j = () => c.A.setLockState("unlock", "")
background.js.pretty:50024 (close a process by id)
const n = await i.Uc.windows.closeApp(t, { processId: e }, 35e3);
return Boolean(n.success)
background.js.pretty:53382 (the teacher action that calls it)
f = (0, u.S)("teacher/closeWindowsApp", async (e, { dispatch: t }) => {
await o.A.closeApp(e) && t(p())
})
Running-process list refreshes
on REFRESH_WINDOWS_APPS_INTERVAL_MS at :53361;
the desktop screenshot path is :52107
(getDesktopImage()), falling back to a tab capture.
14. The native host was never installed (live probe)
PowerShell, test VM, 2026-11-05
absent: HKCU:\Software\Google\Chrome\NativeMessagingHosts\chromium.goguardian.ggwindows
absent: HKLM:\SOFTWARE\Google\Chrome\NativeMessagingHosts\chromium.goguardian.ggwindows
absent: HKLM:\SOFTWARE\WOW6432Node\Google\Chrome\NativeMessagingHosts\chromium.goguardian.ggwindows
registered native hosts on this machine:
com.microsoft.browsercore (HKCU + HKLM)
com.microsoft.defender.browser_extension.native_message_host (HKLM)
Get-Process |? Name -match 'guard|gg|shield' -> (no output)
Program Files folders matching guard|gg -> (no output)
Chrome queries the 32-bit view
first, then 64-bit; all three were empty. Cross-check: the auth token's
ggdi claim is self-generated:<uuid> (source 7),
the fallback used when no native host supplies a device identity.
15. No eval anywhere; executeScript only gets fixed code
$ grep -n '[^.\\w(]eval(' work/4.1.246_0/*.pretty
(no matches)
$ grep -n 'new Function(' work/4.1.246_0/*.pretty
checkin.js.pretty:7877: return this || new Function("return this")()
goguardian-17.js.pretty:4565: u = new Function(a, "_", o) // lodash template
… (same lodash boilerplate in 6 files)
background.js.pretty:53818-53824 (the most dynamic injection in the bundle)
const s = async e => {
const t = await (0, r.I)({
target: e.target,
world: "ISOLATED",
func: () => self.origin
}), …
The executeScript
wrapper at :54094-54105 dedupes by hashing the request; call
sites pass files: from the fixed content-script list or a
hardcoded func:. No server-supplied string reaches either.
16. The updater: stable.xml, latestShieldVersion, reload in 10s
og-backup/4.1.246_0/manifest.json:162
"update_url": "https://ext.goguardian.com/stable.xml",
background.js.pretty:49845-49899 (server names a version, Chrome acts)
predicate: t.extSettings.latestShieldVersion !== previous latestShieldVersion
effect: const i = chrome.runtime.getManifest().version,
o = n.extSettings.latestShieldVersion;
if (i !== o) … await chrome.runtime.requestUpdateCheck() // :49863
onUpdateAvailable: major bump → warn("major update available, reloading")
same major → defer while a teacher session runs
h = () => { setTimeout(() => { chrome.runtime.reload() }, 1e4) } // :49897
background.js.pretty:53653 health check re-injects content scripts every 25 s
background.js.pretty:1809 chrome.management.getAll()
background.js.pretty:45188 → POST /v1/admin/extensions/sync (SyncInstalledExtensions)
Wire evidence:
GET /v1/ext-settings returned "latestShieldVersion":
"4.1.247" while the files on disk read "version": "4.1.246",
and extension-version: 4.1.247 sat on all 1,414 GoGuardian
requests from the capture's first flow at 17:12:46.