Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions apps/bridge/src/services/__tests__/marketplace-plugin-pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
assertPluginInstallPin,
isFloatingPluginRef,
resolvePluginPinPolicy,
} from "../marketplace-plugin-pin.js";
import type { CatalogEntry } from "../marketplace-catalog.js";

function entry(partial: Partial<CatalogEntry>): CatalogEntry {
return {
id: "demo-plugin",
kind: "plugin",
installType: "plugin",
title: "Demo",
description: "",
version: "1.0.0",
author: "test",
pluginRepo: "https://github.com/example/demo.git",
...partial,
};
}

describe("marketplace-plugin-pin", () => {
it("treats main/master/empty as floating", () => {
expect(isFloatingPluginRef(undefined)).toBe(true);
expect(isFloatingPluginRef("main")).toBe(true);
expect(isFloatingPluginRef("MASTER")).toBe(true);
expect(isFloatingPluginRef("v1.2.3")).toBe(false);
expect(isFloatingPluginRef("abc1234")).toBe(false);
});

it("requires pins for Official/Community and SaaS-shaped sources", () => {
expect(
resolvePluginPinPolicy({
entry: entry({ sourceName: "Official" }),
})
).toBe("required");
expect(
resolvePluginPinPolicy({
entry: entry({ sourceName: "Community" }),
})
).toBe("required");
expect(
resolvePluginPinPolicy({
entry: entry({}),
sourceCatalog:
"https://app.godmode.software/api/marketplace/commerce/catalog/official/public",
})
).toBe("required");
expect(
resolvePluginPinPolicy({
entry: entry({ pluginLocalPath: "/tmp/local-plugin" }),
})
).toBe("optional");
});

it("fail-closes Official installs without a pinned pluginRef", () => {
expect(() =>
assertPluginInstallPin(entry({ sourceName: "Official", pluginRef: "main" }), "required")
).toThrow(/pinned pluginRef/);
expect(() =>
assertPluginInstallPin(entry({ sourceName: "Official" }), "required")
).toThrow(/pinned pluginRef/);
});

it("accepts tag or commit pins", () => {
expect(
assertPluginInstallPin(
entry({ pluginRef: "v1.0.0", pluginDigest: "deadbeefcafebabe" }),
"required"
)
).toEqual({ ref: "v1.0.0", digest: "deadbeefcafebabe" });
expect(
assertPluginInstallPin(entry({ pluginRef: "abcdef1" }), "required")
).toEqual({ ref: "abcdef1", digest: undefined });
});
});
54 changes: 35 additions & 19 deletions apps/bridge/src/services/marketplace-catalog.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { execSync } from "node:child_process";
import { v4 as uuidv4 } from "uuid";
import { config } from "../config.js";
import { getCoreDb, type CoreDatabase } from "../core-db.js";
Expand All @@ -24,6 +23,11 @@ import {
hasPaidEntitlementForCatalogEntry,
MarketplaceCommerceError,
} from "./marketplace-commerce.js";
import {
assertPluginInstallPin,
materializePinnedPluginCheckout,
resolvePluginPinPolicy,
} from "./marketplace-plugin-pin.js";

export type CatalogInstallType = "clone" | "plugin";

Expand All @@ -38,7 +42,10 @@ export interface CatalogEntry {
tags?: string[];
bundlePath?: string;
pluginRepo?: string;
/** Immutable git tag or commit for Official/Community installs (#177). */
pluginRef?: string;
/** Optional full/prefix commit sha; fail closed if HEAD drifts (#177). */
pluginDigest?: string;
/** Install from an existing local directory (no git clone). */
pluginLocalPath?: string;
previewPath?: string;
Expand Down Expand Up @@ -446,9 +453,18 @@ export async function uninstallDiscoveredPlugin(
async function installPluginEntry(
core: CoreDatabase,
tenantId: string,
entry: CatalogEntry
): Promise<{ pluginId: string; pluginRoot: string; restartRequired: boolean; built: boolean }> {
const ref = entry.pluginRef ?? "main";
entry: CatalogEntry,
sourceCatalog?: string
): Promise<{
pluginId: string;
pluginRoot: string;
restartRequired: boolean;
built: boolean;
pluginRef: string;
pluginDigest?: string;
}> {
const policy = resolvePluginPinPolicy({ entry, sourceCatalog });
const pin = assertPluginInstallPin(entry, policy);
const dirName = entry.id.replace(/[^a-z0-9-]/gi, "-");
let target: string;
let built = false;
Expand All @@ -472,20 +488,13 @@ async function installPluginEntry(
}
target = path.join(marketplacePluginsDir(), dirName);
const cloneUrl = authenticatedGitCloneUrl(entry.pluginRepo);
if (fs.existsSync(target)) {
try {
execSync("git pull", { cwd: target, stdio: "pipe" });
} catch {
fs.rmSync(target, { recursive: true, force: true });
execSync(`git clone --depth 1 --branch ${ref} ${cloneUrl} ${target}`, {
stdio: "pipe",
});
}
} else {
execSync(`git clone --depth 1 --branch ${ref} ${cloneUrl} ${target}`, {
stdio: "pipe",
});
}
materializePinnedPluginCheckout({
target,
cloneUrl,
ref: pin.ref,
digest: pin.digest,
entryId: entry.id,
});
if (!bridgeEntryExists(target)) {
await ensurePluginBuilt(target, {
tenantId,
Expand All @@ -504,6 +513,8 @@ async function installPluginEntry(
pluginRoot: target,
restartRequired: false,
built,
pluginRef: pin.ref,
pluginDigest: pin.digest,
};
}

Expand Down Expand Up @@ -567,7 +578,12 @@ export async function installCatalogEntry(
let result: Record<string, unknown>;

if (entry.installType === "plugin") {
result = await installPluginEntry(core, opts.tenantId, entry);
result = await installPluginEntry(
core,
opts.tenantId,
entry,
opts.sourceCatalog ?? catalogUrl
);
} else {
const bundle = await fetchBundleJson(entry, index, catalogUrl);
const imported = importEntity(tenantDb, bundle);
Expand Down
160 changes: 160 additions & 0 deletions apps/bridge/src/services/marketplace-plugin-pin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Buyer protection (#177): pin Official/Community plugin installs to an
* immutable git ref (tag or commit). Fail closed on floating refs and digest drift.
*/
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import type { CatalogEntry } from "./marketplace-catalog.js";
import { config } from "../config.js";

const FLOATING_REFS = new Set(["", "main", "master", "head", "origin/main", "origin/master"]);

export type PluginPinPolicy = "required" | "optional";

export function normalizePluginRef(ref: string | undefined | null): string {
return String(ref ?? "").trim();
}

export function isFloatingPluginRef(ref: string | undefined | null): boolean {
const normalized = normalizePluginRef(ref).toLowerCase();
return FLOATING_REFS.has(normalized);
}

export function isCommitLikeRef(ref: string): boolean {
return /^[0-9a-f]{7,40}$/i.test(ref.trim());
}

/**
* Official and Community catalog installs must pin. Local folder installs and
* operator third-party Local catalogs may omit a pin (self-host convenience).
*/
export function resolvePluginPinPolicy(opts: {
entry: CatalogEntry;
sourceCatalog?: string;
}): PluginPinPolicy {
if (opts.entry.pluginLocalPath?.trim()) return "optional";
const sourceName = String(opts.entry.sourceName ?? "").toLowerCase();
if (sourceName.includes("official") || sourceName.includes("community")) {
return "required";
}
const src = String(
opts.sourceCatalog ?? opts.entry.sourceCatalog ?? ""
).toLowerCase();
if (
src.includes("/catalog/official") ||
src.includes("commerce/catalog/official") ||
src.includes("godmode-marketplace")
) {
return "required";
}
if (config.isSaas && opts.entry.pluginRepo?.trim()) return "required";
return "optional";
}

export function assertPluginInstallPin(
entry: CatalogEntry,
policy: PluginPinPolicy
): { ref: string; digest?: string } {
const ref = normalizePluginRef(entry.pluginRef);
const digest = normalizePluginRef(
(entry as CatalogEntry & { pluginDigest?: string }).pluginDigest
);

if (policy === "required") {
if (isFloatingPluginRef(ref)) {
throw new Error(
`Catalog entry "${entry.id}" is missing a pinned pluginRef (tag or commit). ` +
`Floating refs like main/master are not allowed for Official/Community installs (#177).`
);
}
}

const effectiveRef = ref || "main";
if (policy === "optional" && isFloatingPluginRef(ref) && !digest) {
return { ref: effectiveRef };
}
if (digest && !/^[0-9a-f]{7,40}$/i.test(digest)) {
throw new Error(
`Catalog entry "${entry.id}" has an invalid pluginDigest (expected hex commit sha).`
);
}
return { ref: effectiveRef, digest: digest || undefined };
}

export function readGitHead(pluginRoot: string): string {
return execSync("git rev-parse HEAD", {
cwd: pluginRoot,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
}).trim();
}

export function assertGitHeadMatchesDigest(
pluginRoot: string,
digest: string,
entryId: string
): void {
const head = readGitHead(pluginRoot).toLowerCase();
const want = digest.trim().toLowerCase();
if (head === want || head.startsWith(want) || want.startsWith(head)) return;
throw new Error(
`Pinned digest mismatch for "${entryId}": expected ${digest}, got ${head}. Fail closed (#177).`
);
}

/** Clone or reset a plugin checkout to an immutable ref; verify digest when set. */
export function materializePinnedPluginCheckout(opts: {
target: string;
cloneUrl: string;
ref: string;
digest?: string;
entryId: string;
}): void {
const { target, cloneUrl, ref, digest, entryId } = opts;
const run = (cmd: string, cwd?: string) =>
execSync(cmd, { cwd, stdio: "pipe", encoding: "utf8" });

const freshClone = () => {
if (fs.existsSync(target)) {
fs.rmSync(target, { recursive: true, force: true });
}
fs.mkdirSync(path.dirname(target), { recursive: true });
if (isCommitLikeRef(ref)) {
run(`git clone --depth 1 ${shellQuote(cloneUrl)} ${shellQuote(target)}`);
run(`git fetch --depth 1 origin ${shellQuote(ref)}`, target);
run(`git checkout --force ${shellQuote(ref)}`, target);
} else {
run(
`git clone --depth 1 --branch ${shellQuote(ref)} ${shellQuote(cloneUrl)} ${shellQuote(target)}`
);
}
};

if (!fs.existsSync(target)) {
freshClone();
} else {
try {
run("git rev-parse --is-inside-work-tree", target);
run(`git fetch --depth 1 origin ${shellQuote(ref)}`, target);
if (isCommitLikeRef(ref)) {
run(`git checkout --force ${shellQuote(ref)}`, target);
} else {
run(`git checkout --force FETCH_HEAD`, target);
}
} catch {
freshClone();
}
}

if (digest) {
assertGitHeadMatchesDigest(target, digest, entryId);
} else if (isCommitLikeRef(ref)) {
assertGitHeadMatchesDigest(target, ref, entryId);
}
}

function shellQuote(value: string): string {
if (/^[A-Za-z0-9_./:@+-]+$/.test(value)) return value;
return `"${value.replace(/"/g, '\\"')}"`;
}
13 changes: 13 additions & 0 deletions docs/MARKETPLACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ Point non-SaaS installs at the public URL with `MARKETPLACE_SAAS_OFFICIAL_URL` /

Open **Marketplace → Official** to browse. Free entries install immediately. Paid entries require checkout (card / PayPal / crypto), then **Install if owned**.

### Buyer install pins (#177)

Official and Community **plugin** installs fail closed unless the catalog entry
sets an immutable `pluginRef` (release tag or commit sha). Optional
`pluginDigest` (commit sha) must match `git rev-parse HEAD` after checkout.
Floating refs (`main` / `master`) are rejected for those catalogs. Local folder
registration (self-host / Unofficial) stays operator-trusted and does not use
this pin gate.

Intake CI smoke does not replace install pins or runtime least privilege.
Runtime capability allowlists (network deny-by-default for plugins) are tracked
as follow-up #290.

## Community (user-to-user)

1. Seller: **Sell** → accept ToS → connect payout (required for paid) → publish with kind, title, price, delivery (`clone` or `live`), and source resource id.
Expand Down
20 changes: 20 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ Layers 1–3 (plus shared PTY #162) and `cursor_cloud` SDK sandbox (#171) are a
**Layer 4** (SaaS staging/prod recommended via #178): ephemeral npm builds via a host build supervisor (`deploy/build-supervisor/`). Prefer a **separate supervisor or token per data root** so SaaS builds never bind private_hub tenant paths. Default build network is `none`; staging/prod demos use `allowlist` (Docker `--internal` network + host CONNECT proxy). Residual risks: the supervisor is a privileged Docker client on the host; compose publishes supervisor/egress ports on all host interfaces so Linux Bridge containers can reach them via `host.docker.internal` (loopback-only publish breaks host-gateway); operators must firewall WAN NICs and rotate `CODING_BUILD_SUPERVISOR_TOKEN`; a same-named non-internal Docker network fails closed until removed. Non-HTTP egress (SSH / `git@` / arbitrary TCP) is deferred for SaaS defaults (#173): use HTTPS remotes for private git; trusted single-tenant hosts may set `CODING_TERMINAL_NET=shared` if they truly need SSH. Full `shared` build networks remain out of scope. Shared-host VM-grade isolation is #172.

| Local plugin path registration | Tenant RCE via arbitrary folders | Blocked on SaaS unless `PLATFORM_SAAS_ALLOW_LOCAL_PLUGINS` |
| Marketplace Official/Community plugin install | Floating `main` pulls or digest drift after intake verify | **Buyer pin (#177):** install requires immutable `pluginRef` (tag or commit); optional `pluginDigest` must match `HEAD`. No `git pull` to latest. Local folder installs remain operator-trusted. Runtime capability allowlists are a follow-up |
| Installed plugin code in Bridge process | Malicious plugin APIs against tenant data after install | Distinct from coding jail (#112) and from Marketplace intake CI. Kill switches (#96). True plugin process/container sandbox is optional later |
| Federation API token | Remote command injection if token leaks | Rotate tokens; restrict network access |
| First signup admin | Race on internet-exposed fresh installs | Use invite codes, paywall, or pre-seed `INITIAL_ADMINS` |
| Plugin bundles (`/api/plugins/*/web.js`) | Proprietary JS exposure | Requires authenticated tenant + installed plugin |
Expand Down Expand Up @@ -172,6 +174,24 @@ Paid Marketplace plugins are still host-privileged code once installed — revie
sources before install. Chargebacks on delivered software ban Marketplace access
per [MARKETPLACE_TOS.md](MARKETPLACE_TOS.md).

## Marketplace buyer protection (threat boundaries)

Intake verification (Marketplace CI smoke / optional review) raises the floor but
does **not** guarantee benign runtime behavior after install. Keep these
boundaries distinct:

| Boundary | What it covers | What it does not |
|----------|----------------|------------------|
| **Intake** (Marketplace verify VM / Actions) | Public repo pin, one-shot smoke before listing | Time bombs, env-gated exfil, abuse of allowed APIs on the buyer hub |
| **Buyer install pin (#177)** | Official/Community installs must use immutable `pluginRef` (tag or commit); optional `pluginDigest` fail-closed on drift; no floating `main` / `git pull` | Capability allowlists, confirmations, CSP (follow-up) |
| **Coding jail (#112 Layers 1–4)** | Per-tenant coding root + bubblewrap terminal/helpers on a **shared** Bridge host | Isolating *installed plugin* code (plugins load in the Bridge Node process today) |
| **VM-grade coding jobs (#172)** | Disposable machines for untrusted build/coding jobs | Per-plugin runtime sandbox on the buyer hub |
| **Kill switches (#96)** | Deploy/spend/send/agent emergency stops | Fine-grained plugin capability grants |

Extending CI smoke or Copilot review on intake is encouraged and still not
sufficient alone. A true plugin-per-process/container sandbox remains optional
and expensive.

## Reporting

Open a private security advisory on GitHub for vulnerabilities in the public core. Do not commit secrets, wallet keys, or operator `.env` files.
Loading
Loading