Skip to the content.

Update 2026-07-17 (later) — Companion v0.4.26 removes the file capture, and addon 3.3.25 makes the error-log upload opt-in

Date: 2026-07-17 Author: Jesper (JesperLive / MrSataana), developer of GRIP - Enhanced Macro Sequencer (GRIP-EMS) Subject: GSE Companion v0.4.26, and GSE addon build 3.3.25 Previous update: UPDATE-2026-07-17-v0.4.24.md

My last update, written earlier today, ended on this sentence: GSE removed the ability to destroy a competitor’s data and kept the ability to collect it. That is no longer accurate, and this update exists to say so.

v0.4.26 removes the collection half. The unsigned, server-triggered routine that read arbitrary files under Interface\AddOns and WTF and uploaded them is gone. The server can no longer name a file. The paths field it used is not read anywhere in the build, the recursive directory walker that resolved bare filenames has been deleted, and the capture-denied machinery around it went with them. In its place the server can set one boolean, requestFiles, whose entire effect is a desktop notification asking you to attach files yourself through the “Report a problem” form.

Addon 3.3.25 does the matching half in-game. The BugSack/BugGrabber error-log upload — which 0.4.22 had added to the always-on gather — is now an unticked checkbox in the support window, and the Companion strips errorlogs out of any server-triggered gather regardless.

Bearing: this is the finding I have been asking for. Taken with the v0.4.24 write guard, both halves of the competitor-facing subsystem I documented in June are now gone from the shipped client: it can no longer write to my addon’s files, and it can no longer read them on the server’s instruction. I have spent five updates describing what this client could do to my data. It is only fair to be equally precise now that the answer has changed.

What has not changed, and I am not going to bury it: the unsigned auto-updater is untouched, and it remains the largest capability in the app. The signed engine’s read operation still has no basename guard. And one indirect path to my addon’s contents survives — described in full below, because a removal I overstate is worth as little as a risk I understate.

Chain of custody

Artifact SHA-256 Size
GSE Companion Setup 0.4.26.exe c720ec821818fa2b58a4e50d71dbbd0c06c81c01ec573b5e2a4505554d0780d7 81,327,286 B
resources/app.asar (from the installer) c5e569a768acf03bfbe7fe8aa9a9d6a9c4a52f534fa913cc374f90534a57ac21 6,210,073 B
out/main/index.js (inside that asar) db3f8f5c1d747d05c701cfb0e05538a065c90fbf14d51535b6e3ca882f53b7e3 136,850 B
GSE-3.3.25.zip (free build) ea15d65ba23f91eae0381b2302fabe41443829289c8cc6522dffa34c631c08eb 2,561,801 B
GSE-3.3.25-PatronBuild.zip (patron build) a62bc47e28b3ae14a7781703a76963d66e7596747bd0b7960a894e704948ab85 2,571,012 B

The app.asar extracted from the 0.4.26 installer is byte-identical to the installed one at %LOCALAPPDATA%\Programs\gse-companion\resources\app.asar, so the code analysed here is the code that runs on my machine. As a control on my own method I re-hashed the 0.4.24 installer and got d912618652c9cfdb3efb5d23e9cd78ed6a3810f02dad5f41a8546c0cf381d76d, matching the value published in the previous update.

v0.4.24’s out/main/index.js was 136,477 bytes; v0.4.26’s is 136,850, a difference of 373. The asar grew by 7,532, which is mostly the renderer: out/renderer/assets/ was rebuilt under new content-hash filenames and out/preload/index.mjs grew 6,372 → 6,557 bytes. That is the new attach-files UI, and it is consistent with the change described here.

There is no 0.4.25 in this account. 0.4.24 and 0.4.26 are the two builds I hold. I do not know whether 0.4.25 was released and pulled, released and missed by me, or never released. I am recording the gap rather than papering over it.

What was removed

The server can no longer name a file

The companion:request dispatch has the same three branches as before — task (the signed engine), idx (the code self-attestation channel cleared in the previous update), and the fall-through. The fall-through is what changed:

} else if (t?.type === "companion:request")
    if (t.task)
        Qo(t.task).catch(() => {});
    else if (Array.isArray(t.idx))
    Jo(t.requestId, t.idx).catch(() => {});
else {
    const n = Array.isArray(t.kinds) ? t.kinds : [];
    if (console.log(`[sse] companion:request requestId=${t.requestId} kinds=${n.join(",")} requestFiles=${!!t.requestFiles}`), n.length && Ho(t.requestId, n).catch((s) => {
            console.warn("[diagnostic] fulfilment failed:", s && s.message);
        }), t.requestFiles) {
        try {
            C?.webContents.send("report:files-requested", {
                note: t.note || null
            });
        } catch {}
        try {
            new bt({
                title: "GSE Companion",
                body: 'Support has asked you to attach files to your report — open "Report a problem" to add them.'
            }).show();
        } catch {}
    }
}

In v0.4.24 this branch read Qo(t.requestId, t.kinds || [], t.paths || []) — three arguments, the third server-supplied. In v0.4.26 it reads Ho(t.requestId, n) — two, and n is t.kinds. The gather function’s signature lost its paths parameter to match.

The string .paths does not occur anywhere in the 0.4.26 build. In 0.4.20 through 0.4.24 it occurred twice.

kinds cannot smuggle a path

The obvious question is whether the capability just moved into kinds. It did not. Ho is the whole server-triggered path:

async function Ho(e, t) {
    if (!e) return;
    if (!_) { console.warn("[diagnostic] no auth — skipping"); return; }
    const n = new Set((Array.isArray(t) ? t : []).map((r) => String(r).toLowerCase()));
    n.delete("errorlogs");
    const s = ys(n);
    ...

and ys treats kinds as a closed set of three flags, not as data:

There is no branch anywhere in ys that treats a member of kinds as a filesystem path. An unrecognised kind is ignored.

The walker and its caps are gone

qo — the function that resolved server-supplied entries — still exists by name, but it is a different function with one caller. The recursive directory walker it used to call (function(e,t,n,s){ ... V.isDirectory() ? recurse : name === target && push }) has been deleted outright. withFileTypes occurrences drop from 2 to 1; the survivor is the mod-list reader, which reads directory names and .toc headers, not file bodies. The 40,000-walk-entry cap (4e4) is gone because there is no longer a walk to cap.

Marker counts, 0.4.19 → 0.4.26

Produced by marker_scan.py (extended for this build; the tool is in the audit set, not written for this document):

marker 0.4.19 0.4.20 0.4.21 0.4.22 0.4.23 0.4.24 0.4.26
capture-denied 0 1 1 1 1 1 0
capture- 0 3 3 3 3 3 0
.paths 0 2 2 2 2 2 0
4e4 (walk cap) 0 1 1 1 1 1 0
withFileTypes 1 2 2 2 2 2 1
userFile: 0 0 0 0 0 0 1
requestFiles 0 0 0 0 0 0 3
userFilePaths 0 0 0 0 0 0 4
excludeMods 0 0 0 0 0 0 4
includeErrorLogs 0 0 0 0 0 0 5
report:files-requested 0 0 0 0 0 0 1
ed25519 key 1 1 1 1 1 1 1
sign.detached.verify 1 1 1 1 1 1 1
--force-run 1 1 1 1 1 1 1
autoApplyUpdates 2 2 2 2 2 2 2
integrityRef 4 4 4 4 0 0 0
restrictedAccount 5 5 5 5 0 0 0

The four GRIP-EMS identifiers remain absent in plain, base64, hex and reversed encodings across every build tested.

What replaced it: the user picks the files

The attach path is user-driven end to end, and the chain is short enough to give in full.

report:submit is an IPC handler — it is called by the renderer, which is the local UI, with source: "in-app":

O.handle("report:submit", async (e, t) => As({
    description: t?.description,
    includeModList: !!t?.includeModList,
    excludeMods: Array.isArray(t?.excludeMods) ? t.excludeMods : [],
    includeErrorLogs: !!t?.includeErrorLogs,
    userFilePaths: Array.isArray(t?.userFilePaths) ? t.userFilePaths : [],
    source: "in-app"
}));

and As assembles the report:

const c = new Set(["gsesv", "companionsv", "log", "settings"]);
s && c.add("errorlogs");                       // s = includeErrorLogs
let i = [];
try { i = ys(c); } catch {}
if (Array.isArray(o) && o.length)              // o = userFilePaths
    try { i.push(...qo(o)); } catch {}
let l = [];
if (t)                                          // t = includeModList
    try {
        const h = new Set((Array.isArray(n) ? n : []).map(String));
        l = ws().filter((y) => !h.has(y.name));  // n = excludeMods
    } catch {}

qo is the only consumer of userFilePaths, and it has exactly one call site — the line above. It still enforces the realpath scope check against Interface\AddOns and WTF and a 40-file cap, and it tags what it collects userFile: rather than capture-.

As has two call sites, not one, and I checked the second only on a later pass. The other is fr() at line 4091, which is the in-game report path and is the missing link between this build and addon 3.3.25:

if (g.GSESupportReports)                       // g = the parsed SavedVariables/GSE.lua
    try { await fr(g.GSESupportReports); } catch (m) { ... }     // line 2950
...
async function fr(e) {                          // line 4091
    ...
    const a = await As({
        description: r,
        includeModList: !!s.includeMods,
        includeErrorLogs: !!s.includeErrorLogs,
        source: "in-game"
    });

So the full chain of the support-request restructure is: you fill in GSE’s in-game support window, which writes GSESupportReports (a SavedVariable that is new in 3.3.25); the Companion’s sync loop reads SavedVariables/GSE.lua, finds the queue, and uploads each entry, deduplicating on uploadedSupportReportIds. That is why the error-log consent had to be captured in-game: the in-game form is what the Companion uploads, so the tick-box has to live where the report is written.

It does not weaken the user-driven conclusion, and I checked rather than assumed: fr passes no userFilePaths, so qo is unreachable from it, and its includeErrorLogs is the value of the tick-box the user set in-game. Both callers of As are user-initiated — one from the Companion’s own form, one from the game’s. Neither takes a path from the server.

The honest note is about method rather than result: I published the “user-driven end to end” claim above having enumerated one of the two call sites. The claim survived the second one. It did not have to.

So a user can still attach GRIP-EMS.lua to a GSE support report. The difference is that a user choosing to send me-shaped data to GSE is a user exercising judgement about their own files, which was never the thing I objected to. The server choosing it for them was.

excludeMods is new alongside it: the mod list is still gathered, but the user can drop entries from it before it is sent.

The addon half: 3.3.25

Measured by diffing the two zips against 3.3.24-2 rather than reading the changelog.

The Support.lua change is the whole in-game finding:

+    local errorLogsCheck = UI:Create("CheckBox")
+    errorLogsCheck:SetType("checkbox")
+    errorLogsCheck:SetLabel(L["Include my BugSack / BugGrabber error logs (recommended if you are seeing in-game errors)"])
+    errorLogsCheck:SetValue(false)
+    frame:AddChild(errorLogsCheck)

with includeErrorLogs = errorLogsCheck:GetValue() and true or false added to the submitted payload, and the box reset to false after each send. It defaults off.

This directly answers the 0.4.22 finding in hashes.txt: “0.4.22 adds a BugGrabber/BugSack SavedVariables reader to the always-on gather, so their error-log content is attached to every diagnostic upload.” It is now neither always-on nor server-reachable.

Re-verified directly on 3.3.25, both builds:

The in-game addon remains inert with respect to competitor targeting.

What did not change

The updater, which is still the largest capability in the app

Unchanged on every one of the five properties from the previous update’s teardown, re-checked line by line rather than carried forward:

Property 0.4.24 0.4.26
Asset selection reads only fileWin/fileMac/fileLinux, no digest/hash/signature yes yes (L5274-5275)
autoApplyUpdates defaults to !0, gate is !== !1 yes yes (L74, L5208)
Spawns the installer with /S --force-run yes yes (L5240)
HTTP 426 on a sync triggers an out-of-band release check; exactly 2 occurrences yes yes (L1992 test, L2003 rethrow)
content-length feeds the progress bar and is compared to nothing yes yes (L5290)

The 0.4.26 installer is unsigned (Get-AuthenticodeSignatureNotSigned), as 0.4.23 and 0.4.24 were.

So the asymmetry from the previous update stands, and is now starker rather than softer. The two competitor-facing capabilities have both been removed. The capability that can download an executable from a server-named asset id and run it silently as you, with no signature check and no hash check, is exactly where it was. It affects every GSE user identically, including GSE’s own, and it is still the answer to “what can the server make this app do”.

The signed engine can still read my addon’s file

So — the engine’s read primitive — has only the path-scope check:

function So(e, t) {
    if (!fs(e, t)) throw new Error("path out of scope");
    return $e(e);
}

The write guard added in 0.4.24 is retained and unchanged, one function below it:

const ko = /^GSE.*\.lua$/i;
function $o(e, t, n) {
    if (!fs(e, n)) throw new Error("path out of scope");
    if (!ko.test(ze(e))) throw new Error("write refused: not a GSE SavedVariables file");

GRIP-EMS.lua still fails the write guard and still passes the read scope check.

The one indirect path that survives

With the capture gone, this is the only route from GSE’s server to the contents of my addon’s save file, and it is worth stating precisely because it is easy to overstate in either direction.

ys calls Po() unconditionally, on every gather, including the server-triggered one. Po() collects every file matching Ao = /^GSE.*\.lua$/i plus GSE’s own GSE_Companion_Data.lua, and Ho uploads the result to /diagnostic/upload. A companion:request with any non-empty kinds therefore uploads GSE’s own SavedVariables with no user prompt. That is unchanged from 0.4.24, and on its own it is unremarkable — those are GSE’s files.

The chain is that the signed engine can read GRIP-EMS.lua into a binding and write it into a file named GSE*.lua, which then satisfies Po()’s filter on the next server-triggered gather.

Four things bound this, and all four matter:

  1. It requires a validly ed25519-signed directive. The key is embedded (b531cb8b…683a), the signature check has exactly one call site, and nothing else reaches the write.
  2. It is two steps, not one — a signed write, then a triggered gather.
  3. I noted the read-then-write laundering possibility in the v0.4.24 update already. What changed is its standing: it was a footnote when a one-step unsigned capture could do the same job directly. It is now the only path there is.
  4. I have never observed a directive of any kind being sent, in this or any capture.

I am not presenting this as a new capability. It is an old one that the removals have left exposed as the last of its kind.

The bridge addon, which I should have written about five builds ago

While auditing this build I looked at something I had never examined: Interface\AddOns\GSE_Companion, the addon that carries the desktop app’s data into the game. It is not new in 0.4.26 and it is not a change in this build. It is a gap in my own coverage, and the honest place for it is here rather than nowhere.

It is installed by the desktop application, not by any addon channel. The three files are copied out of a payload inside the Electron app:

function rs(e) {
    const t = P(e, "Interface", "AddOns", ss);            // ss = "GSE_Companion"
    if (!W(P(e, "Interface", "AddOns"))) return !1;
    W(t) || Ns(t, { recursive: !0 });
    const n = lo();                                        // resources/addon
    ...
    } catch {
        s = ["GSE_Companion.toc", "Bootstrap.lua", "GSE_Companion.lua"];
    }
    for (const r of s) { const a = P(n, r), c = P(t, r); W(a) && Zn(a, c); }
    const o = P(t, os);                                    // GSE_Companion_Data.lua
    return W(o) || Te(o, `GSECompanionData = {}\n`, { encoding: "latin1" }), !0;
}

Verified on disk: the three files in my Interface\AddOns\GSE_Companion\ are byte-identical to %LOCALAPPDATA%\Programs\gse-companion\resources\addon\. Neither the free nor the patron 3.3.25 zip contains the folder; 7z l on the installers shows the payload present and unchanged in 0.4.20, 0.4.22, 0.4.24 and 0.4.26 (Bootstrap.lua 260 B, GSE_Companion.lua 35,663 B), so it has shipped this way since at least 0.4.20.

How the bridge works. Inbound, the app writes GSE_Companion_Data.lua — a plain Lua assignment to GSECompanionData — into the addon folder, and WoW loads it as an ordinary addon file at /reload. Outbound, the app reads GSE’s SavedVariables after WoW exits; the in-game side says so in its own user-facing string: “your edit will sync back when WoW closes.” It is a sensible answer to the fact that addons cannot do network I/O.

Two observations, neither of them alarming:

The one thing worth noting against Blizzard’s policy point 2 — “The programming code of an add-on must in no way be hidden or obfuscated, and must be freely accessible to and viewable by the general public” — is distribution, not obfuscation. The Lua is perfectly readable once extracted. But it is an add-on distributed inside an .exe rather than as add-on code, so reading it requires knowing it is in there. It is a comment on the text, not a ruling. Blizzard decides what its policy means.

A note on that comparison, added later the same day. An earlier version of this paragraph said the point was “the same shape as the GSE_QoL point-2 reading in my previous update”. That comparison is withdrawn, because the GSE_QoL reading was wrong and has been retractedGSE_QoL is public source in GSE’s own GitHub repository and has been since 2024-07-07, so point 2 is satisfied there. See the retraction in UPDATE-2026-07-17-v0.4.24.md.

The GSE_Companion observation does not fall with it — if anything the retraction sharpens it. GSE_QoL turned out to be exactly where point 2 expects add-on code to be: in the public repo. GSE_Companion is not, and I checked four ways on 2026-07-17: the repo path returns 404; TimothyLuke’s 19 public repositories contain no Companion repo; the GSE repo’s 18 branches contain no Companion branch; and GitHub global code search for GSE_Companion_Initialize and for GSECompanionData returns 0 results each. Every other GSE add-on module — GSE, GSE_GUI, GSE_LDB, GSE_Options, GSE_Utils, GSE_QoL — is public source. This one is not, and it is the one carrying the bridge to the server. That asymmetry is the whole observation, and it is still arguable rather than decisive: the installer is free, and every user has the Lua in the clear on disk once it is installed. The limit of my check is also worth stating — GitHub code search does not index everything and covers only GitHub, so I have not established that the source is unpublished everywhere, only that it is not on GitHub.

What I am not claiming

How to verify this yourself

The removal (v0.4.26):

  1. Extract resources/app.asar from the installer (7z e "<installer>.exe" -o<dir> -y, then 7z e "<dir>\app-64.7z" "resources\app.asar" -o<dir> -y). Confirm the SHA-256 against the table above, and against your installed copy at %LOCALAPPDATA%\Programs\gse-companion\resources\app.asar.
  2. npx --yes @electron/asar extract app.asar out — the .unpacked native-module errors are harmless. Beautify out/main/index.js with npx js-beautify so the line numbers here line up.
  3. Search for the literal .paths. Expect zero hits. In 0.4.20-0.4.24 there are two.
  4. Search for capture-denied. Expect zero. It is present in 0.4.20-0.4.24.
  5. Find the companion:request dispatch by shape (t.task / t.idx / else). Confirm the else-branch calls the gather with two arguments and that neither is t.paths.
  6. Read the gather (Ho in this build). Confirm n.delete("errorlogs") on the second line of its body, and that the function it calls (ys) branches on errorlogs / log / settings only — no branch treats a kind as a path.
  7. Confirm userFilePaths reaches the collector from ipcMain.handle("report:submit") with source: "in-app", and that the collector has exactly one call site.

The retained updater:

  1. Confirm the asset selector still reads only fileWin/fileMac/fileLinux with no digest field, that autoApplyUpdates defaults to !0, and that the spawn is still ["/S", "--force-run"].
  2. Get-AuthenticodeSignature "GSE Companion Setup 0.4.26.exe"NotSigned.

The addon (3.3.25):

  1. Diff the free and patron zips against 3.3.24-2. Expect 165 free / 169 patron, the single new file being release.json.
  2. Open GSE_Options/Support.lua and confirm errorLogsCheck is created with SetValue(false).
  3. Confirm GSE/API/Serialisation.lua:8 still writes "!GSE3!", and that every !GSE3!+ occurrence is a comparison.

Traps that cost me time on this build:

Method corrections carried from this pass

  1. My previous update’s closing framing was overtaken within a day. “Removed the ability to destroy and kept the ability to collect” was accurate for 0.4.24 and is wrong for 0.4.26. A conclusion pinned to a build number needs re-testing per build, and a memorable sentence is the most dangerous kind to carry forward.
  2. A filter that cannot match looks identical to a clean result. The findstr alternation error would have published a zero I had not actually measured. Zero results now require a control that must match, run in the same command.
  3. The bridge addon went unexamined for six builds. It is benign, and I did not know that until I read it. The same gap-shape as the idx branch in the previous update: not examined, therefore not claimable either way.