Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepfix/audit-sweep-2026-07-26gatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
secret-egress.test.ts6.3 KB · 155 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
/**
 * No user-controlled execution path receives platform secrets.
 *
 * Two real breaches of this rule, both found by auditing rather than by any
 * test:
 *
 *  - src/lib/preview-builder.ts ran `sh -c <repo.previewBuildCommand>` — a
 *    string any repo owner sets from the repo settings form — with the FULL
 *    process.env, and captured stdout/stderr into `buildLog`, which is
 *    rendered back to that same user. `previewBuildCommand = "env"` therefore
 *    printed DATABASE_URL (production Neon credentials), ANTHROPIC_API_KEY,
 *    GLUECRON_PAT, WORKFLOW_SECRETS_KEY, SERVER_TARGETS_KEY and the OAuth
 *    secrets into a page the attacker controls. Complete platform compromise
 *    from a settings field.
 *
 *  - src/lib/hosted-claude-loop.ts passed ANTHROPIC_API_KEY and GLUECRON_PAT
 *    into the env of arbitrary user-supplied JavaScript whose stdout is
 *    returned to the caller.
 *
 * src/lib/workflow-runner.ts already had the correct pattern — a curated
 * allowlist plus a credential-shaped denylist — for user `run:` steps. The
 * rule these tests enforce is that EVERY user-code path uses it, because the
 * threat model is identical and the decision should not be made twice.
 */

import { describe, expect, it } from "bun:test";
import { readFileSync } from "fs";

const SECRET_NAMES = [
  "ANTHROPIC_API_KEY",
  "GLUECRON_PAT",
  "DATABASE_URL",
  "WORKFLOW_SECRETS_KEY",
  "SERVER_TARGETS_KEY",
  "GOOGLE_OAUTH_CLIENT_SECRET",
  "STRIPE_SECRET_KEY",
  "EMERGENCY_PAT_SECRET",
];

/**
 * Every site that executes a user-supplied command or script. Adding a new one
 * without adding it here is what this suite is designed to catch — see the
 * "no unlisted shell-exec sites" test below.
 */
const USER_CODE_PATHS = [
  "src/lib/workflow-runner.ts", // user `run:` steps from workflow YAML
  "src/lib/preview-builder.ts", // repo.previewBuildCommand
  "src/lib/hosted-claude-loop.ts", // user-authored loop source
];

describe("user-code paths do not inherit the process environment", () => {
  for (const path of USER_CODE_PATHS) {
    it(`${path} never spreads process.env into a spawn`, () => {
      const src = readFileSync(path, "utf8")
        .replace(/\/\*[\s\S]*?\*\//g, "")
        .replace(/^\s*\/\/.*$/gm, "");
      // `env: { ...process.env }` is the exact shape that leaked everything.
      expect(src).not.toMatch(/env:\s*\{\s*\.\.\.process\.env/);
    });
  }
});

describe("no secret is named in a user-code path's env construction", () => {
  for (const path of USER_CODE_PATHS) {
    const src = readFileSync(path, "utf8")
      .replace(/\/\*[\s\S]*?\*\//g, "")
      .replace(/^\s*\/\/.*$/gm, "");
    for (const secret of SECRET_NAMES) {
      it(`${path} does not read process.env.${secret}`, () => {
        // Assert on the READ, not the mention. workflow-runner names these
        // secrets in its RUNNER_ENV_DENYLIST, which is the correct thing to
        // do — a substring check would flag the very defence being asserted.
        //
        // hosted-claude-loop still reads ANTHROPIC_API_KEY, but only behind an
        // explicit operator opt-in, asserted separately below.
        if (path.includes("hosted-claude-loop") && secret === "ANTHROPIC_API_KEY") return;
        expect(src).not.toMatch(new RegExp(`process\\.env\\.${secret}\\b`));
        expect(src).not.toMatch(new RegExp(`process\\.env\\[["'\`]${secret}["'\`]\\]`));
      });
    }
  }
});

describe("the shared allowlist is the only way user code gets an env", () => {
  it("preview-builder uses buildRunnerEnv rather than its own env object", () => {
    const src = readFileSync("src/lib/preview-builder.ts", "utf8");
    expect(src).toContain("buildRunnerEnv");
  });

  it("workflow-runner still filters through allowlist AND denylist", () => {
    const src = readFileSync("src/lib/workflow-runner.ts", "utf8");
    expect(src).toContain("RUNNER_ENV_ALLOWLIST");
    expect(src).toContain("RUNNER_ENV_DENYLIST");
    // The denylist is the net: even an allowlisted key that looks like a
    // credential is dropped.
    expect(src).toMatch(/RUNNER_ENV_DENYLIST\.test\(key\)/);
  });

  it("the allowlist contains no secret-shaped names", () => {
    const src = readFileSync("src/lib/workflow-runner.ts", "utf8");
    const list = src.slice(
      src.indexOf("const RUNNER_ENV_ALLOWLIST"),
      src.indexOf("];", src.indexOf("const RUNNER_ENV_ALLOWLIST"))
    );
    for (const secret of SECRET_NAMES) expect(list).not.toContain(secret);
    expect(list).not.toMatch(/KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL/);
  });
});

describe("hosted loops keep the platform key opt-in", () => {
  // Comments stripped: this file documents the original bug, and that prose
  // necessarily contains the very strings being asserted against.
  const src = readFileSync("src/lib/hosted-claude-loop.ts", "utf8")
    .replace(/\/\*[\s\S]*?\*\//g, "")
    .replace(/^\s*\/\/.*$/gm, "");

  it("the operator PAT is never injected", () => {
    expect(src).not.toMatch(/env\.GLUECRON_PAT\s*=/);
    expect(src).not.toMatch(/GLUECRON_PAT:/);
  });

  it("the Anthropic key requires an explicit opt-in flag", () => {
    expect(src).toContain('process.env.HOSTED_LOOPS_PLATFORM_KEY === "1"');
    // Never as a bare property on the env literal — that was the bug.
    expect(src).not.toMatch(/^\s*ANTHROPIC_API_KEY:/m);
  });
});

describe("no unlisted shell-exec site", () => {
  it("every `sh -c` / `bash -c` spawn lives in a declared user-code path", () => {
    const { readdirSync, statSync } = require("fs") as typeof import("fs");
    const { join } = require("path") as typeof import("path");
    const walk = (d: string): string[] =>
      readdirSync(d).flatMap((e) => {
        const p = join(d, e);
        return statSync(p).isDirectory() ? walk(p) : [p];
      });

    const found: string[] = [];
    for (const dir of ["src/lib", "src/routes", "src/hooks"]) {
      for (const f of walk(dir)) {
        if (!/\.tsx?$/.test(f)) continue;
        const src = readFileSync(f, "utf8").replace(/^\s*\/\/.*$/gm, "");
        if (/\[\s*["'`](?:ba)?sh["'`]\s*,\s*["'`]-c["'`]/.test(src)) {
          found.push(f.replace(/\\/g, "/"));
        }
      }
    }
    // A new shell-exec site must be reviewed and added to USER_CODE_PATHS,
    // which subjects it to every assertion above.
    expect(found.sort()).toEqual(
      USER_CODE_PATHS.filter((p) => found.includes(p)).sort()
    );
  });
});