Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit4c08073

fix(security): hosted loops handed the operator PAT and platform API key to user code

fix(security): hosted loops handed the operator PAT and platform API key to user code

`loop.sourceCode` is arbitrary user-supplied JavaScript. Creating a loop
requires only requireAuth (claude-deploy.tsx:54), so any registered account
can run whatever it likes, and the run's stdout is captured and returned to
the caller. Anything in the subprocess env is therefore readable with one
console.log — and the env carried both:

  - GLUECRON_PAT — the OPERATOR's admin-scoped token
  - ANTHROPIC_API_KEY — the platform's Anthropic billing key

So any registered user could exfiltrate the operator's admin credentials and
the platform's API key. A loop marked isPublic widens the invoke path to
unauthenticated callers as well (POST /loops/:slug/invoke).

- GLUECRON_PAT: removed outright. Snippets needing callbacks use their own
  token from the create wizard; there was never a reason to expose the
  operator's.
- ANTHROPIC_API_KEY: now injected only when an operator explicitly sets
  HOSTED_LOOPS_PLATFORM_KEY=1. Default is to withhold it, so an existing
  trusted deployment can opt back in while the default is safe.

Note the subprocess already did NOT inherit process.env — these two were
being passed deliberately, so the sandboxing was never the gap.

Sandboxing user code that legitimately needs to call Claude is the real
long-term fix: proxy the calls through the platform so no raw key is ever
handed out. Filed as follow-up, not attempted here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccantynz-alt committed on July 27, 2026Parent: 45ef109
2 files changed+8244c080730724216a7fa899a3168964178da6403db
2 changed files+82−4
Addedsrc/__tests__/hosted-loop-secrets.test.ts+65−0View fileUnifiedSplit
1/**
2 * Hosted Claude loops must not hand platform secrets to user code.
3 *
4 * `loop.sourceCode` is arbitrary user-supplied JavaScript. Creating a loop
5 * requires only requireAuth (src/routes/claude-deploy.tsx:54), so any
6 * registered account can run whatever it wants, and the run's stdout is
7 * captured and returned to the caller. Anything placed in the subprocess env
8 * is readable with a single console.log.
9 *
10 * The env used to carry:
11 * - GLUECRON_PAT — the OPERATOR's admin-scoped token
12 * - ANTHROPIC_API_KEY — the platform's billing key
13 *
14 * i.e. any registered user could exfiltrate both.
15 */
16
17import { describe, expect, it } from "bun:test";
18import { readFileSync } from "fs";
19
20const SRC = readFileSync("src/lib/hosted-claude-loop.ts", "utf8");
21const envBlock = SRC.slice(
22 SRC.indexOf("const env: Record<string, string> = {"),
23 SRC.indexOf("const exec = getExecutor();")
24);
25
26describe("operator PAT is never exposed", () => {
27 it("GLUECRON_PAT is not injected into the snippet env", () => {
28 expect(envBlock).not.toMatch(/env\.GLUECRON_PAT\s*=/);
29 expect(envBlock).not.toMatch(/GLUECRON_PAT:/);
30 });
31
32 it("is gone from the whole module, not just relocated", () => {
33 const assignments = SRC.match(/env\.GLUECRON_PAT\s*=/g) ?? [];
34 expect(assignments.length).toBe(0);
35 });
36});
37
38describe("platform Anthropic key is opt-in", () => {
39 it("is not unconditionally placed in the env", () => {
40 // The original bug: a bare property on the object literal.
41 expect(envBlock).not.toMatch(/^\s*ANTHROPIC_API_KEY:/m);
42 });
43
44 it("requires an explicit operator opt-in flag", () => {
45 expect(envBlock).toContain('process.env.HOSTED_LOOPS_PLATFORM_KEY === "1"');
46 });
47
48 it("defaults to withholding the key", () => {
49 // Guard against the flag being inverted later: absence of the env var
50 // must mean no key.
51 const gate = envBlock.slice(envBlock.indexOf("HOSTED_LOOPS_PLATFORM_KEY"));
52 expect(gate).toContain("env.ANTHROPIC_API_KEY =");
53 expect(envBlock.indexOf("HOSTED_LOOPS_PLATFORM_KEY")).toBeLessThan(
54 envBlock.indexOf("env.ANTHROPIC_API_KEY =")
55 );
56 });
57});
58
59describe("what the snippet legitimately still gets", () => {
60 it("keeps the non-secret loop identifiers", () => {
61 expect(envBlock).toContain("GLUECRON_AGENT_ID");
62 expect(envBlock).toContain("GLUECRON_LOOP_ID");
63 expect(envBlock).toContain("GLUECRON_BASE_URL");
64 });
65});
Modifiedsrc/lib/hosted-claude-loop.ts+17−4View fileUnifiedSplit
642642 };
643643 }
644644
645 // `loop.sourceCode` is arbitrary user-supplied code — creating a loop needs
646 // only requireAuth, so any registered account can run whatever it likes
647 // here, and stdout is captured and handed back to the caller. Anything put
648 // in this env is therefore readable by that user with one console.log.
649 //
650 // Two secrets used to be here:
651 // - GLUECRON_PAT: the OPERATOR's admin-scoped token. There was never a
652 // reason to expose it; snippets that need to call back use their own
653 // token from the wizard. Removed outright.
654 // - ANTHROPIC_API_KEY: the platform's billing key. Handing it to
655 // untrusted code lets any user drain the account or use it elsewhere.
656 // Now off by default and only injected when an operator explicitly
657 // opts in via HOSTED_LOOPS_PLATFORM_KEY=1, so an existing trusted
658 // deployment can keep working while the default is safe.
645659 const env: Record<string, string> = {
646660 PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
647661 HOME: process.env.HOME || "/tmp",
648 // Pass the platform's Claude key (NOT the user's). When unset, the
649 // snippet still runs — the Anthropic SDK throws which becomes stderr.
650 ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || "",
651662 // The loop's own agent token — minted at creation time, hashed in
652663 // agent_sessions. The plaintext is only handed to the user at create
653664 // and never persisted, so we expose the loop's session id here as
658669 GLUECRON_BASE_URL:
659670 process.env.APP_BASE_URL || "https://gluecron.com",
660671 };
661 if (process.env.GLUECRON_PAT) env.GLUECRON_PAT = process.env.GLUECRON_PAT;
672 if (process.env.HOSTED_LOOPS_PLATFORM_KEY === "1") {
673 env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || "";
674 }
662675
663676 const exec = getExecutor();
664677 let execResult: ExecResult;
665678