From 3240c3f9b833031500bac705fb59753909f88fe5 Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Fri, 31 Jul 2026 04:41:59 +0700 Subject: [PATCH 1/2] fix(usage-limits): scope the Antigravity process scan to the current user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `detectAntigravityProcess` ran `/bin/ps -ax`, which walks every account on the box, and attached to whichever Antigravity language server matched first. Tracing what that could expose settles the question #132 left open: the CSRF token and the pid never reach an HTTP response. `processInfo` is read field by field and is never spread into a returned object — the pid goes to `listAntigravityPorts`, the token becomes a request header, and all four return shapes of `fetchAntigravityLimits` carry neither. What the token *fetches* does reach the response. `normalizeAntigravityResponse` returns `account_email` and `account_plan`, `finalize` spreads them into the result, and `getUsageLimits` serves that at `/functions/tokentracker-usage-limits`. On a shared host this displayed another person's email, plan and quota as the local user's own, and `writeAntigravityLimitsCache` persisted the address to disk, where the not-configured branch kept serving it after their process exited. So: not credential exposure, but cross-account PII — on multi-user hosts only, which is why no observed output could have caught it. The scan now uses the PS_BINARY / PS_ARGS already exported by process-list.js rather than its own inline argv. Sharing the constant is the point: #129 fixed the other scan, and two scans that must both stay own-user should not be able to drift apart. The regression test captures the literal argv from an injected commandRunner, because a real `ps` run on a single-user machine returns identical lines either way. Verified as a guard rather than a restatement: restoring `-ax` fails it. Anyone who has run this on a shared host may have another user's account_email cached in ~/.tokentracker/tracker/usage-limits-cache.json. It is a cache; deleting the file is the whole remedy. Closes #132 --- src/lib/usage-limits.js | 22 ++++++++++++++++++++-- test/usage-limits.test.js | 27 +++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/lib/usage-limits.js b/src/lib/usage-limits.js index 25533940..f4805b40 100644 --- a/src/lib/usage-limits.js +++ b/src/lib/usage-limits.js @@ -21,7 +21,7 @@ const { extractCursorSessionToken, fetchCursorUsageSummary, } = require("./cursor-config"); -const { parseProcessLine } = require("./process-list"); +const { PS_ARGS, PS_BINARY, parseProcessLine } = require("./process-list"); // 2-minute in-memory cache let cache = { data: null, fetchedAt: 0 }; @@ -1350,8 +1350,26 @@ function extractCommandFlag(command, flag) { return match?.[1] || null; } +// Scans for the local Antigravity language server and reads its `--csrf_token` +// out of the command line, so that the quota request below can authenticate to +// it. +// +// The scan is scoped to the current user, and must stay that way. Under the +// previous `-ax` this walked every account on the box and attached to whichever +// Antigravity matched first. The token itself never reached an HTTP response — +// `processInfo` is read field by field and never spread into a return value — +// but what the token *fetches* does: `normalizeAntigravityResponse` returns +// `account_email` and `account_plan`, `finalize` spreads them into the result, +// and `getUsageLimits` serves that at `/functions/tokentracker-usage-limits`. +// On a shared host this meant showing another person's email, plan and quota as +// the local user's, and `writeAntigravityLimitsCache` persisted it to disk, +// where the `!configured` branch would keep serving it after their process +// exited. +// +// Sharing PS_ARGS with process-list.js is deliberate: two scans that must both +// stay own-user should not be able to drift apart. function detectAntigravityProcess({ commandRunner } = {}) { - const result = runCommand(commandRunner, "/bin/ps", ["-ax", "-o", "pid=,command="], { + const result = runCommand(commandRunner, PS_BINARY, PS_ARGS, { timeout: 4000, }); const lines = String(result?.stdout || "").split("\n"); diff --git a/test/usage-limits.test.js b/test/usage-limits.test.js index cf078133..76b5dadf 100644 --- a/test/usage-limits.test.js +++ b/test/usage-limits.test.js @@ -1271,6 +1271,33 @@ lang 123 me 23u IPv4 0x124 0t0 TCP 127.0.0.1:51235 (LIS assert.equal(result.extensionPort, 42427); }); + // The scan must stay scoped to the user running TokenTracker. Under `-ax` this + // walked every account on the box and attached to whichever Antigravity + // matched first — authenticating with that person's CSRF token and then + // serving their `account_email`, plan and quota at + // `/functions/tokentracker-usage-limits` as if they were the local user's, + // and caching it to disk. + // + // Asserted on the literal argv rather than on observed output, because a real + // `ps` run on a single-user machine returns the same lines either way and + // cannot catch the regression. + it("scans only the current user's processes", () => { + let invocation = null; + const commandRunner = (command, args) => { + invocation = { command, args }; + return { stdout: "", status: 0 }; + }; + + detectAntigravityProcess({ commandRunner }); + + assert.equal(invocation.command, "/bin/ps"); + assert.deepEqual(invocation.args, ["-x", "-o", "pid=,command="]); + assert.ok( + !invocation.args.some((arg) => /^-[a-z]*a/.test(arg)), + "ps must not be invoked with the all-users flag", + ); + }); + it("persists live Antigravity quota for use after the process exits", async () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tokentracker-antigravity-cache-write-")); try { From 5ab5d33ef4aaea7758cca8cfb25f9bd6b5be9759 Mon Sep 17 00:00:00 2001 From: "itarun.p" Date: Fri, 31 Jul 2026 04:50:34 +0700 Subject: [PATCH 2/2] fix(process-list): verify `-x` on Linux and freeze the shared argv MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps a review found in the previous commit. The `-x` scoping was only ever demonstrated on macOS, where `ps` is BSD and `-x` plainly means "own user, tty restriction lifted". Linux `ps` is procps and parses dash-prefixed options as UNIX-style, where `-x` is not an option — and `isProcessListSupported` returns true for every platform except win32, so Linux runs this argv. Had procps rejected it, `listProcessLines` would have returned `process_list_failed` on every Linux host: a permanent non-advisory warn, pinning `degraded` for a whole platform, which is the failure #136 exists to remove. Checked on a real Debian 12 / procps-ng 4.0.2 host rather than reasoned from the manual: `ps -x -o pid=,command=` exits 0 and reports one user across 22 lines, while `-ax` on the same box reports seven users across 39. procps accepts it as the BSD `x`, so the argv is correct on both supported platforms and #129's shipped code is correct too. Recorded in the comment so nobody has to re-derive it from a manpage. The array is now frozen. Two modules share it, and the claim written in the previous commit — that the two scans "cannot drift apart" — was only true of editorial drift. Importing one constant does not stop `PS_ARGS.push("-a")`; freezing does, and the test asserts the mutation throws rather than asserting the flag alone. Refs #132 --- src/lib/process-list.js | 15 ++++++++++++++- test/process-list.test.js | 12 ++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/lib/process-list.js b/src/lib/process-list.js index e67a3201..0a6e78f0 100644 --- a/src/lib/process-list.js +++ b/src/lib/process-list.js @@ -17,7 +17,20 @@ const PS_BINARY = "/bin/ps"; // that endpoint answer questions about other people's sessions. Scoping the scan // itself is the narrow fix: a session TokenTracker could not have recorded // anyway is one this user is not running. -const PS_ARGS = ["-x", "-o", "pid=,command="]; +// +// Verified on both supported platforms rather than assumed from documented +// semantics, because Linux `ps` is procps and parses dash-prefixed options as +// UNIX-style, where `-x` is not an option at all. It does accept this as the BSD +// `x`: on Debian 12 / procps-ng 4.0.2, `ps -x -o pid=,command=` exits 0 and +// lists one user, while `-ax` on the same box lists seven. macOS/BSD `ps` is the +// native case. Had procps rejected it, every Linux host would have fallen into +// `process_list_failed` — a permanent non-advisory warn, which would pin +// `degraded` for a whole platform. +// +// Frozen because two modules now share this array. Importing one constant stops +// the two scans from drifting apart editorially; freezing is what stops a caller +// pushing `-a` onto it at runtime. +const PS_ARGS = Object.freeze(["-x", "-o", "pid=,command="]); const PS_TIMEOUT_MS = 4000; const PS_MAX_BUFFER = 10 * 1024 * 1024; diff --git a/test/process-list.test.js b/test/process-list.test.js index 1c9569dc..7bf2063e 100644 --- a/test/process-list.test.js +++ b/test/process-list.test.js @@ -27,6 +27,18 @@ test("the process scan is scoped to the current user", () => { ); }); +// Both `ps` call sites import this one array, which stops them drifting apart +// when someone edits one of them. It does not stop a caller mutating the shared +// object at runtime, and "cannot drift apart" is only true of a frozen one. +test("the shared argv cannot be widened at runtime", () => { + assert.equal(Object.isFrozen(PS_ARGS), true); + assert.throws(() => { + "use strict"; + PS_ARGS.push("-a"); + }, TypeError); + assert.deepEqual(PS_ARGS, ["-x", "-o", "pid=,command="]); +}); + test("parseProcessLine splits a pid from a command and rejects junk", () => { assert.deepEqual(parseProcessLine(" 4211 /usr/local/bin/claude --model x"), { pid: 4211,