From d4bd8b2fdc09757527f5cfb5d4aee3785c6b236b Mon Sep 17 00:00:00 2001 From: Muhammad Rafiq <101276427+mohabbis@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:04:54 -0500 Subject: [PATCH] Add Ghost-authenticated Claude Code plugin --- cloud/.env.example | 12 +- cloud/apps/mcp/src/index.ts | 12 +- .../apps/web/src/app/(app)/settings/page.tsx | 21 ++- cloud/apps/web/src/app/api/agent/route.ts | 2 +- .../settings/agent-credentials/[id]/route.ts | 25 ++++ .../api/settings/agent-credentials/route.ts | 56 ++++++++ .../components/settings/agent-credentials.tsx | 126 +++++++++++++++++ cloud/apps/web/src/lib/agent-auth.ts | 73 ++++++---- cloud/docs/AGENT_PLUGIN.md | 64 ++++----- cloud/docs/CURSOR_HANDOFF.md | 4 +- cloud/packages/core/package.json | 3 +- .../migration.sql | 20 +++ cloud/packages/core/prisma/schema.prisma | 23 +++ .../core/src/agent/credentials.test.ts | 16 +++ cloud/packages/core/src/agent/credentials.ts | 20 +++ cloud/turbo.json | 4 +- .../ghost/.claude-plugin/plugin.json | 8 ++ plugins/claude-code/ghost/.mcp.json | 10 ++ plugins/claude-code/ghost/README.md | 23 +++ plugins/claude-code/ghost/bin/ghost-mcp.mjs | 133 ++++++++++++++++++ .../ghost/skills/run-workflow/SKILL.md | 13 ++ 21 files changed, 587 insertions(+), 81 deletions(-) create mode 100644 cloud/apps/web/src/app/api/settings/agent-credentials/[id]/route.ts create mode 100644 cloud/apps/web/src/app/api/settings/agent-credentials/route.ts create mode 100644 cloud/apps/web/src/components/settings/agent-credentials.tsx create mode 100644 cloud/packages/core/prisma/migrations/20260728210000_agent_credentials/migration.sql create mode 100644 cloud/packages/core/src/agent/credentials.test.ts create mode 100644 cloud/packages/core/src/agent/credentials.ts create mode 100644 plugins/claude-code/ghost/.claude-plugin/plugin.json create mode 100644 plugins/claude-code/ghost/.mcp.json create mode 100644 plugins/claude-code/ghost/README.md create mode 100644 plugins/claude-code/ghost/bin/ghost-mcp.mjs create mode 100644 plugins/claude-code/ghost/skills/run-workflow/SKILL.md diff --git a/cloud/.env.example b/cloud/.env.example index 43553d4..204615a 100644 --- a/cloud/.env.example +++ b/cloud/.env.example @@ -38,12 +38,10 @@ APP_URL="http://localhost:3000" # --------------------------------------------------------------------------- # Agent plugin / MCP (optional — local dogfood) -# Agents may list/preview/start runs; they cannot approve. See -# cloud/docs/AGENT_PLUGIN.md. Generate a key: openssl rand -hex 32 -# Org/user ids come from your Membership row after first sign-in. +# Agents may list/preview/start runs; they cannot approve. Sign in and create a +# revocable credential in Settings. This value is used only by the MCP process, +# not by the web app. # --------------------------------------------------------------------------- -GHOST_AGENT_API_KEY="" -GHOST_AGENT_ORG_ID="" -GHOST_AGENT_USER_ID="" -# MCP bridge only (apps/mcp) — where the web app listens: +# GHOST_ACCESS_TOKEN="ghost_agent_…" +# MCP bridge only (apps/mcp) — where Ghost listens: # GHOST_API_URL="http://localhost:3000" diff --git a/cloud/apps/mcp/src/index.ts b/cloud/apps/mcp/src/index.ts index fd9735c..5e087f1 100644 --- a/cloud/apps/mcp/src/index.ts +++ b/cloud/apps/mcp/src/index.ts @@ -6,8 +6,7 @@ * * Env: * GHOST_API_URL default http://localhost:3000 - * GHOST_AGENT_API_KEY required (same key as the web app) - * (web also needs GHOST_AGENT_ORG_ID + GHOST_AGENT_USER_ID) + * GHOST_ACCESS_TOKEN required (created in Ghost Settings) * * Cursor example (~/.cursor/mcp.json): * { @@ -18,7 +17,7 @@ * "cwd": "/cloud", * "env": { * "GHOST_API_URL": "http://localhost:3000", - * "GHOST_AGENT_API_KEY": "…" + * "GHOST_ACCESS_TOKEN": "ghost_agent_…" * } * } * } @@ -97,7 +96,10 @@ async function handle( case "tools/list": return ok(id, toolList()); case "tools/call": { - const p = (params ?? {}) as { name?: string; arguments?: Record }; + const p = (params ?? {}) as { + name?: string; + arguments?: Record; + }; const name = typeof p.name === "string" ? p.name : ""; if (!name) return fail(id, -32602, "tools/call requires name"); try { @@ -124,7 +126,7 @@ async function handle( } async function main() { - const apiKey = requireEnv("GHOST_AGENT_API_KEY"); + const apiKey = requireEnv("GHOST_ACCESS_TOKEN"); const baseUrl = process.env.GHOST_API_URL?.trim() || "http://localhost:3000"; const client = new GhostAgentClient({ baseUrl, apiKey }); diff --git a/cloud/apps/web/src/app/(app)/settings/page.tsx b/cloud/apps/web/src/app/(app)/settings/page.tsx index 19b82bd..dee5a46 100644 --- a/cloud/apps/web/src/app/(app)/settings/page.tsx +++ b/cloud/apps/web/src/app/(app)/settings/page.tsx @@ -1,6 +1,7 @@ import { auth } from "@/auth"; import { prisma } from "@/lib/db"; import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card"; +import { AgentCredentials } from "@/components/settings/agent-credentials"; export const dynamic = "force-dynamic"; @@ -12,7 +13,10 @@ export default async function SettingsPage() { ? await prisma.organization.findUnique({ where: { id: orgId }, include: { - memberships: { include: { user: true }, orderBy: { createdAt: "asc" } }, + memberships: { + include: { user: true }, + orderBy: { createdAt: "asc" }, + }, }, }) : null; @@ -21,7 +25,9 @@ export default async function SettingsPage() {

Settings

-

Organization and members.

+

+ Organization and members. +

@@ -46,13 +52,20 @@ export default async function SettingsPage() { {(org?.memberships ?? []).map((m) => ( -
+
{m.user.email ?? m.user.name ?? m.userId} - {m.role} + + {m.role} +
))} + +
); } diff --git a/cloud/apps/web/src/app/api/agent/route.ts b/cloud/apps/web/src/app/api/agent/route.ts index a223c7a..bf317da 100644 --- a/cloud/apps/web/src/app/api/agent/route.ts +++ b/cloud/apps/web/src/app/api/agent/route.ts @@ -4,7 +4,7 @@ import { resolveAgentPrincipal } from "@/lib/agent-auth"; /** * Agent surface catalog. - * GET /api/agent — tool list + human-approval contract (session or agent API key). + * GET /api/agent — tool list + human-approval contract (session or Ghost credential). */ export async function GET(req: Request) { const authz = await resolveAgentPrincipal(req); diff --git a/cloud/apps/web/src/app/api/settings/agent-credentials/[id]/route.ts b/cloud/apps/web/src/app/api/settings/agent-credentials/[id]/route.ts new file mode 100644 index 0000000..df40a1b --- /dev/null +++ b/cloud/apps/web/src/app/api/settings/agent-credentials/[id]/route.ts @@ -0,0 +1,25 @@ +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +export async function DELETE( + _req: Request, + context: { params: Promise<{ id: string }> }, +) { + const session = await auth(); + if (!session?.user.id || !session.user.orgId) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } + const { id } = await context.params; + const result = await prisma.agentCredential.updateMany({ + where: { + id, + orgId: session.user.orgId, + userId: session.user.id, + revokedAt: null, + }, + data: { revokedAt: new Date() }, + }); + if (!result.count) + return Response.json({ error: "credential not found" }, { status: 404 }); + return Response.json({ revoked: true }); +} diff --git a/cloud/apps/web/src/app/api/settings/agent-credentials/route.ts b/cloud/apps/web/src/app/api/settings/agent-credentials/route.ts new file mode 100644 index 0000000..424e478 --- /dev/null +++ b/cloud/apps/web/src/app/api/settings/agent-credentials/route.ts @@ -0,0 +1,56 @@ +import { auth } from "@/auth"; +import { createAgentToken } from "@ghost/core/agent-credentials"; +import { prisma } from "@/lib/db"; + +export async function GET() { + const session = await auth(); + if (!session?.user.id || !session.user.orgId) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } + const credentials = await prisma.agentCredential.findMany({ + where: { + orgId: session.user.orgId, + userId: session.user.id, + revokedAt: null, + }, + select: { + id: true, + name: true, + tokenHint: true, + createdAt: true, + lastUsedAt: true, + expiresAt: true, + }, + orderBy: { createdAt: "desc" }, + }); + return Response.json({ credentials }); +} + +export async function POST(req: Request) { + const session = await auth(); + if (!session?.user.id || !session.user.orgId) { + return Response.json({ error: "unauthorized" }, { status: 401 }); + } + const body = (await req.json().catch(() => null)) as { + name?: unknown; + } | null; + const name = typeof body?.name === "string" ? body.name.trim() : ""; + if (!name || name.length > 80) { + return Response.json( + { error: "name must be between 1 and 80 characters" }, + { status: 400 }, + ); + } + const generated = createAgentToken(); + const credential = await prisma.agentCredential.create({ + data: { + orgId: session.user.orgId, + userId: session.user.id, + name, + tokenHash: generated.tokenHash, + tokenHint: generated.tokenHint, + }, + select: { id: true, name: true, tokenHint: true, createdAt: true }, + }); + return Response.json({ credential, token: generated.token }, { status: 201 }); +} diff --git a/cloud/apps/web/src/components/settings/agent-credentials.tsx b/cloud/apps/web/src/components/settings/agent-credentials.tsx new file mode 100644 index 0000000..cfd5ee3 --- /dev/null +++ b/cloud/apps/web/src/components/settings/agent-credentials.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardBody, CardHeader, CardTitle } from "@/components/ui/card"; + +type Credential = { + id: string; + name: string; + tokenHint: string; + createdAt: string; + lastUsedAt: string | null; +}; + +export function AgentCredentials() { + const [credentials, setCredentials] = useState([]); + const [name, setName] = useState("Claude Code"); + const [token, setToken] = useState(null); + const [error, setError] = useState(null); + + async function refresh() { + const res = await fetch("/api/settings/agent-credentials"); + const data = (await res.json()) as { + credentials?: Credential[]; + error?: string; + }; + if (!res.ok) throw new Error(data.error || "Could not load credentials"); + setCredentials(data.credentials ?? []); + } + + useEffect(() => { + refresh().catch((err) => + setError(err instanceof Error ? err.message : "Could not load"), + ); + }, []); + + async function createCredential() { + setError(null); + const res = await fetch("/api/settings/agent-credentials", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name }), + }); + const data = (await res.json()) as { token?: string; error?: string }; + if (!res.ok || !data.token) { + setError(data.error || "Could not create credential"); + return; + } + setToken(data.token); + await refresh(); + } + + async function revoke(id: string) { + setError(null); + const res = await fetch(`/api/settings/agent-credentials/${id}`, { + method: "DELETE", + }); + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + setError(data.error || "Could not revoke credential"); + return; + } + await refresh(); + } + + return ( + + + Claude Code and agent access + + +

+ Create a revocable Ghost credential for the Claude Code plugin or + another MCP client. Agents can propose runs, but approval remains in + Ghost. +

+
+ setName(event.target.value)} + value={name} + /> + +
+ {token && ( +
+

+ Copy this credential now. Ghost will not show it again. +

+ + {token} + +
+ )} + {error &&

{error}

} +
+ {credentials.map((credential) => ( +
+
+

{credential.name}

+

+ {credential.tokenHint} +

+
+ +
+ ))} + {!credentials.length && ( +

+ No active agent credentials. +

+ )} +
+
+
+ ); +} diff --git a/cloud/apps/web/src/lib/agent-auth.ts b/cloud/apps/web/src/lib/agent-auth.ts index 0544d93..56e11f6 100644 --- a/cloud/apps/web/src/lib/agent-auth.ts +++ b/cloud/apps/web/src/lib/agent-auth.ts @@ -1,14 +1,13 @@ -import { timingSafeEqual } from "node:crypto"; import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import { hashAgentToken } from "@ghost/core/agent-credentials"; /** * Principal for agent HTTP / MCP calls. * * Two paths: * 1. Browser session (NextAuth) — same as the dashboard. - * 2. Bearer `GHOST_AGENT_API_KEY` — local MCP / early agent access, bound to - * `GHOST_AGENT_ORG_ID` + `GHOST_AGENT_USER_ID` (env). Per-org DB keys are - * follow-up; this is the honest early surface. + * 2. A revocable bearer credential created inside the authenticated Ghost app. */ export type AgentPrincipal = { userId: string; @@ -16,13 +15,6 @@ export type AgentPrincipal = { via: "session" | "api_key"; }; -function safeEqualString(a: string, b: string): boolean { - const ab = Buffer.from(a); - const bb = Buffer.from(b); - if (ab.length !== bb.length) return false; - return timingSafeEqual(ab, bb); -} - function bearerToken(req: Request): string | null { const header = req.headers.get("authorization"); if (!header) return null; @@ -35,25 +27,50 @@ function bearerToken(req: Request): string | null { */ export async function resolveAgentPrincipal( req: Request, -): Promise<{ ok: true; principal: AgentPrincipal } | { ok: false; status: number; error: string }> { +): Promise< + | { ok: true; principal: AgentPrincipal } + | { ok: false; status: number; error: string } +> { const token = bearerToken(req); - const expected = process.env.GHOST_AGENT_API_KEY?.trim() || ""; if (token) { - if (!expected || !safeEqualString(token, expected)) { + const credential = await prisma.agentCredential.findUnique({ + where: { tokenHash: hashAgentToken(token) }, + select: { + id: true, + orgId: true, + userId: true, + revokedAt: true, + expiresAt: true, + }, + }); + if ( + !credential || + credential.revokedAt || + (credential.expiresAt && credential.expiresAt <= new Date()) + ) { return { ok: false, status: 401, error: "invalid agent api key" }; } - const orgId = process.env.GHOST_AGENT_ORG_ID?.trim() || ""; - const userId = process.env.GHOST_AGENT_USER_ID?.trim() || ""; - if (!orgId || !userId) { - return { - ok: false, - status: 503, - error: - "agent api key configured but GHOST_AGENT_ORG_ID / GHOST_AGENT_USER_ID are missing — set them to your org and user ids from the dashboard", - }; - } - return { ok: true, principal: { orgId, userId, via: "api_key" } }; + const membership = await prisma.membership.findUnique({ + where: { + userId_orgId: { userId: credential.userId, orgId: credential.orgId }, + }, + select: { id: true }, + }); + if (!membership) + return { ok: false, status: 401, error: "invalid agent api key" }; + await prisma.agentCredential.update({ + where: { id: credential.id }, + data: { lastUsedAt: new Date() }, + }); + return { + ok: true, + principal: { + orgId: credential.orgId, + userId: credential.userId, + via: "api_key", + }, + }; } const session = await auth(); @@ -62,6 +79,10 @@ export async function resolveAgentPrincipal( } return { ok: true, - principal: { orgId: session.user.orgId, userId: session.user.id, via: "session" }, + principal: { + orgId: session.user.orgId, + userId: session.user.id, + via: "session", + }, }; } diff --git a/cloud/docs/AGENT_PLUGIN.md b/cloud/docs/AGENT_PLUGIN.md index 2f34788..e2d3916 100644 --- a/cloud/docs/AGENT_PLUGIN.md +++ b/cloud/docs/AGENT_PLUGIN.md @@ -25,12 +25,13 @@ hard to sell). Do not block engineering on naming. ## What shipped in this surface -| Piece | Path | Notes | -|---|---|---| -| Tool catalog | `@ghost/core/agent` | Allow-listed tools + explicit forbid list | -| HTTP API | `/api/agent/*` | Session cookie **or** bearer `GHOST_AGENT_API_KEY` | -| MCP stdio | `cloud/apps/mcp` | Thin bridge → HTTP invoke | -| Approval | Ghost web UI only | `POST /api/agent/approvals` → **403** | +| Piece | Path | Notes | +| ------------------ | --------------------------- | ---------------------------------------------------- | +| Tool catalog | `@ghost/core/agent` | Allow-listed tools + explicit forbid list | +| HTTP API | `/api/agent/*` | Session cookie **or** Ghost-issued bearer credential | +| MCP stdio | `cloud/apps/mcp` | Thin bridge → HTTP invoke | +| Claude Code plugin | `plugins/claude-code/ghost` | Skill + bundled MCP bridge | +| Approval | Ghost web UI only | `POST /api/agent/approvals` → **403** | ### Agent tools (allow list) @@ -45,29 +46,27 @@ hard to sell). Do not block engineering on naming. Anything that approves or rejects (`approve_run`, `reject_run`, …). Invoke and MCP return an error; there is no approve tool in the catalog. -## Local setup (MCP + API key) +## Claude Code setup (Ghost authentication) 1. Run Ghost Cloud (`pnpm dev` in `cloud/`). -2. Sign in once, create a demo workflow if needed. -3. Copy your **org id** and **user id** (from the DB, or Settings once exposed): +2. Sign in, open **Settings → Claude Code and agent access**, and create a + credential. Ghost shows the plaintext once and stores only its SHA-256 + digest. The credential inherits the signed-in user's organization and can be + revoked from the same screen. +3. Export the credential and load the bundled plugin: ```bash -# example — adjust to your docker/psql -docker compose exec postgres psql -U ghost -d ghost \ - -c 'select m."orgId", m."userId", u.email from "Membership" m join "User" u on u.id = m."userId";' +export GHOST_API_URL="http://localhost:3000" +export GHOST_ACCESS_TOKEN="ghost_agent_…" +claude --plugin-dir ./plugins/claude-code/ghost ``` -4. Add to `cloud/.env`: +4. Ask Claude to list and run a Ghost workflow. When a run reaches + `AWAITING_APPROVAL`, approve in the Ghost app — not in Claude Code. -```bash -GHOST_AGENT_API_KEY="$(openssl rand -hex 32)" -GHOST_AGENT_ORG_ID="" -GHOST_AGENT_USER_ID="" -``` - -Restart web so it picks up the env. Turbo `globalEnv` includes these keys. +## Generic MCP setup -5. Point Cursor (or another MCP client) at the bridge: +Point Cursor or another stdio MCP client at the workspace bridge: ```json { @@ -78,20 +77,20 @@ Restart web so it picks up the env. Turbo `globalEnv` includes these keys. "cwd": "/absolute/path/to/repo/cloud", "env": { "GHOST_API_URL": "http://localhost:3000", - "GHOST_AGENT_API_KEY": "" + "GHOST_ACCESS_TOKEN": "ghost_agent_…" } } } } ``` -6. Ask the agent: “List Ghost workflows and start the demo run.” When it hits - `AWAITING_APPROVAL`, approve in the browser — not in the agent chat. +Ask the agent: “List Ghost workflows and start the demo run.” When it hits +`AWAITING_APPROVAL`, approve in the browser — not in the agent chat. ### curl smoke ```bash -export KEY=… BASE=http://localhost:3000 +export KEY=ghost_agent_… BASE=http://localhost:3000 curl -s -H "Authorization: Bearer $KEY" "$BASE/api/agent" | jq . curl -s -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \ -d '{"name":"list_workflows","arguments":{}}' "$BASE/api/agent/invoke" | jq . @@ -100,15 +99,16 @@ curl -s -X POST -H "Authorization: Bearer $KEY" -H 'content-type: application/js # → 403 ``` -## Auth model (honest / early) +## Auth model -| Path | When | -|---|---| -| Session cookie | Same browser session as the dashboard | -| Bearer `GHOST_AGENT_API_KEY` | Local MCP; identity = `GHOST_AGENT_ORG_ID` + `GHOST_AGENT_USER_ID` | +| Path | When | +| ------------------------------ | -------------------------------------------------------------------------------- | +| Session cookie | Same browser session as the dashboard | +| Ghost-issued bearer credential | Claude Code / MCP; identity is bound to its creating Ghost user and organization | -Per-org rotatable API keys in Postgres are **follow-up**. Until then, treat the -env key as a local/dev secret — not multi-tenant SaaS credentials. +Credentials are random, stored only as hashes, shown once, and individually +revocable. Treat the plaintext like a password. Agents still have no approval +tool, regardless of authentication method. ## Non-goals for this surface diff --git a/cloud/docs/CURSOR_HANDOFF.md b/cloud/docs/CURSOR_HANDOFF.md index 2e4f962..e209961 100644 --- a/cloud/docs/CURSOR_HANDOFF.md +++ b/cloud/docs/CURSOR_HANDOFF.md @@ -90,7 +90,7 @@ hash-chained `AuditEvent`. - Tool catalog: `@ghost/core/agent` (forbid list includes approve/reject) - HTTP: `GET /api/agent`, `POST /api/agent/invoke`, plus REST mirrors under `/api/agent/workflows|runs|approvals` (POST approvals → 403) -- Auth: session **or** bearer `GHOST_AGENT_API_KEY` + env-bound org/user +- Auth: session **or** a hashed, revocable bearer credential created in Ghost Settings - MCP: `pnpm --filter @ghost/mcp exec tsx src/index.ts` - Doc: `cloud/docs/AGENT_PLUGIN.md` @@ -101,7 +101,7 @@ hash-chained `AuditEvent`. 2. **Harden** — per-step timeouts + retry; emergency-stop UI (set `CANCELED`; worker already checks each step); `GET` audit-chain verify using `verifyAuditChain` in `@ghost/core/audit`. -3. **Per-org agent API keys** in Postgres (replace env-bound identity for SaaS). +3. **Credential hardening** — optional expiry and organization-admin inventory/revocation. 4. **SSE/WebSocket** for the timeline (nice-to-have; polling works). 5. **S3 serve path** — disk store works in dev; wire presigned URLs when S3 is on. diff --git a/cloud/packages/core/package.json b/cloud/packages/core/package.json index cff2816..e259c6b 100644 --- a/cloud/packages/core/package.json +++ b/cloud/packages/core/package.json @@ -10,7 +10,8 @@ "./classifier": "./src/classifier/sensitive.ts", "./queue": "./src/queue.ts", "./audit": "./src/audit.ts", - "./agent": "./src/agent/tools.ts" + "./agent": "./src/agent/tools.ts", + "./agent-credentials": "./src/agent/credentials.ts" }, "scripts": { "build": "prisma generate && tsc --noEmit", diff --git a/cloud/packages/core/prisma/migrations/20260728210000_agent_credentials/migration.sql b/cloud/packages/core/prisma/migrations/20260728210000_agent_credentials/migration.sql new file mode 100644 index 0000000..601492c --- /dev/null +++ b/cloud/packages/core/prisma/migrations/20260728210000_agent_credentials/migration.sql @@ -0,0 +1,20 @@ +-- Ghost-issued, revocable credentials for Claude Code and other agent clients. +CREATE TABLE "AgentCredential" ( + "id" TEXT NOT NULL, + "orgId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "tokenHint" TEXT NOT NULL, + "lastUsedAt" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AgentCredential_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AgentCredential_tokenHash_key" ON "AgentCredential"("tokenHash"); +CREATE INDEX "AgentCredential_orgId_createdAt_idx" ON "AgentCredential"("orgId", "createdAt"); +CREATE INDEX "AgentCredential_userId_idx" ON "AgentCredential"("userId"); +ALTER TABLE "AgentCredential" ADD CONSTRAINT "AgentCredential_orgId_fkey" FOREIGN KEY ("orgId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "AgentCredential" ADD CONSTRAINT "AgentCredential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/cloud/packages/core/prisma/schema.prisma b/cloud/packages/core/prisma/schema.prisma index 17cc853..4348456 100644 --- a/cloud/packages/core/prisma/schema.prisma +++ b/cloud/packages/core/prisma/schema.prisma @@ -31,6 +31,7 @@ model User { createdFlows Workflow[] @relation("WorkflowCreatedBy") triggeredRuns Run[] @relation("RunTriggeredBy") resolvedApprovals Approval[] @relation("ApprovalResolvedBy") + agentCredentials AgentCredential[] } model Account { @@ -91,6 +92,28 @@ model Organization { runs Run[] auditEvents AuditEvent[] connectors Connector[] + agentCredentials AgentCredential[] +} + +// Revocable credentials created by an authenticated Ghost user for agent/MCP +// clients. Only a SHA-256 digest is persisted; the plaintext is shown once. +model AgentCredential { + id String @id @default(cuid()) + orgId String + userId String + name String + tokenHash String @unique + tokenHint String + lastUsedAt DateTime? + expiresAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + + org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([orgId, createdAt]) + @@index([userId]) } model Membership { diff --git a/cloud/packages/core/src/agent/credentials.test.ts b/cloud/packages/core/src/agent/credentials.test.ts new file mode 100644 index 0000000..7b6a4b6 --- /dev/null +++ b/cloud/packages/core/src/agent/credentials.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { createAgentToken, hashAgentToken } from "./credentials"; + +describe("agent credentials", () => { + it("creates unique opaque tokens and stable hashes", () => { + const first = createAgentToken(); + const second = createAgentToken(); + + expect(first.token).toMatch(/^ghost_agent_[A-Za-z0-9_-]{43}$/); + expect(first.token).not.toBe(second.token); + expect(first.tokenHash).toBe(hashAgentToken(first.token)); + expect(first.tokenHash).toHaveLength(64); + expect(first.tokenHint).toBe(`ghost_agent_…${first.token.slice(-6)}`); + expect(first.tokenHash).not.toContain(first.token.slice(-6)); + }); +}); diff --git a/cloud/packages/core/src/agent/credentials.ts b/cloud/packages/core/src/agent/credentials.ts new file mode 100644 index 0000000..d2c745c --- /dev/null +++ b/cloud/packages/core/src/agent/credentials.ts @@ -0,0 +1,20 @@ +import { createHash, randomBytes } from "node:crypto"; + +const TOKEN_PREFIX = "ghost_agent_"; + +export function hashAgentToken(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +export function createAgentToken(): { + token: string; + tokenHash: string; + tokenHint: string; +} { + const token = `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`; + return { + token, + tokenHash: hashAgentToken(token), + tokenHint: `${TOKEN_PREFIX}…${token.slice(-6)}`, + }; +} diff --git a/cloud/turbo.json b/cloud/turbo.json index 1d4ac36..298aa3d 100644 --- a/cloud/turbo.json +++ b/cloud/turbo.json @@ -15,9 +15,7 @@ "S3_SECRET_ACCESS_KEY", "APP_URL", "NODE_ENV", - "GHOST_AGENT_API_KEY", - "GHOST_AGENT_ORG_ID", - "GHOST_AGENT_USER_ID", + "GHOST_ACCESS_TOKEN", "GHOST_API_URL" ], "globalPassThroughEnv": [ diff --git a/plugins/claude-code/ghost/.claude-plugin/plugin.json b/plugins/claude-code/ghost/.claude-plugin/plugin.json new file mode 100644 index 0000000..170b123 --- /dev/null +++ b/plugins/claude-code/ghost/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "ghost", + "description": "Connect Claude Code to Ghost's governed workflow runtime. Claude proposes; humans approve in Ghost.", + "version": "0.1.0", + "author": { + "name": "Ghost" + } +} diff --git a/plugins/claude-code/ghost/.mcp.json b/plugins/claude-code/ghost/.mcp.json new file mode 100644 index 0000000..99dbae9 --- /dev/null +++ b/plugins/claude-code/ghost/.mcp.json @@ -0,0 +1,10 @@ +{ + "ghost": { + "command": "node", + "args": ["${CLAUDE_PLUGIN_ROOT}/bin/ghost-mcp.mjs"], + "env": { + "GHOST_API_URL": "${GHOST_API_URL:-http://localhost:3000}", + "GHOST_ACCESS_TOKEN": "${GHOST_ACCESS_TOKEN}" + } + } +} diff --git a/plugins/claude-code/ghost/README.md b/plugins/claude-code/ghost/README.md new file mode 100644 index 0000000..8908191 --- /dev/null +++ b/plugins/claude-code/ghost/README.md @@ -0,0 +1,23 @@ +# Ghost for Claude Code + +This plugin connects Claude Code to Ghost's governed execution runtime. Claude +can inspect workflows, start runs, and report status. It cannot approve gated +steps; approval stays in the authenticated Ghost app. + +## Setup + +1. Sign in to Ghost and open **Settings → Claude Code and agent access**. +2. Create a credential and copy it when shown. +3. Export the credential before starting Claude Code: + + ```bash + export GHOST_API_URL="https://your-ghost.example.com" + export GHOST_ACCESS_TOKEN="ghost_agent_…" + claude --plugin-dir ./plugins/claude-code/ghost + ``` + +4. Ask Claude to list or run a Ghost workflow, or invoke + `/ghost:run-workflow` explicitly. + +Treat the credential like a password. Revoke it from Ghost Settings when a +device no longer needs access. diff --git a/plugins/claude-code/ghost/bin/ghost-mcp.mjs b/plugins/claude-code/ghost/bin/ghost-mcp.mjs new file mode 100644 index 0000000..c9e8480 --- /dev/null +++ b/plugins/claude-code/ghost/bin/ghost-mcp.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import { createInterface } from "node:readline"; + +const tools = [ + [ + "list_workflows", + "List available Ghost workflows.", + { type: "object", properties: {}, additionalProperties: false }, + ], + [ + "get_workflow", + "Preview a Ghost workflow and its current steps.", + { + type: "object", + properties: { workflowId: { type: "string" } }, + required: ["workflowId"], + additionalProperties: false, + }, + ], + [ + "start_run", + "Start a Ghost workflow run. Sensitive steps still require approval in Ghost.", + { + type: "object", + properties: { workflowId: { type: "string" } }, + required: ["workflowId"], + additionalProperties: false, + }, + ], + [ + "get_run", + "Read a Ghost run, its step results, and approval state.", + { + type: "object", + properties: { runId: { type: "string" } }, + required: ["runId"], + additionalProperties: false, + }, + ], + [ + "list_pending_approvals", + "List approvals waiting for a human in Ghost.", + { type: "object", properties: {}, additionalProperties: false }, + ], +].map(([name, description, inputSchema]) => ({ + name, + description, + inputSchema, +})); + +const baseUrl = (process.env.GHOST_API_URL || "http://localhost:3000").replace( + /\/$/, + "", +); +const token = process.env.GHOST_ACCESS_TOKEN?.trim(); +if (!token) { + console.error( + "GHOST_ACCESS_TOKEN is required. Create one in Ghost Settings.", + ); + process.exit(1); +} + +const ok = (id, result) => ({ jsonrpc: "2.0", id: id ?? null, result }); +const fail = (id, code, message) => ({ + jsonrpc: "2.0", + id: id ?? null, + error: { code, message }, +}); + +async function invoke(name, args) { + const response = await fetch(`${baseUrl}/api/agent/invoke`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ name, arguments: args }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) + throw new Error(data.error || `Ghost returned HTTP ${response.status}`); + return data.result ?? data; +} + +async function handle(message) { + const { id, method, params } = message; + if (method === "initialize") + return ok(id, { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "ghost", version: "0.1.0" }, + }); + if (method === "notifications/initialized" || method === "initialized") + return null; + if (method === "ping") return id === undefined ? null : ok(id, {}); + if (method === "tools/list") return ok(id, { tools }); + if (method === "tools/call") { + try { + const result = await invoke(params?.name, params?.arguments || {}); + return ok(id, { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }); + } catch (error) { + return ok(id, { + content: [ + { + type: "text", + text: error instanceof Error ? error.message : "Ghost call failed", + }, + ], + isError: true, + }); + } + } + return id === undefined + ? null + : fail(id, -32601, `method not found: ${method}`); +} + +for await (const line of createInterface({ + input: process.stdin, + crlfDelay: Infinity, +})) { + if (!line.trim()) continue; + let response; + try { + response = await handle(JSON.parse(line)); + } catch { + response = fail(null, -32700, "parse error"); + } + if (response) process.stdout.write(`${JSON.stringify(response)}\n`); +} diff --git a/plugins/claude-code/ghost/skills/run-workflow/SKILL.md b/plugins/claude-code/ghost/skills/run-workflow/SKILL.md new file mode 100644 index 0000000..8821c00 --- /dev/null +++ b/plugins/claude-code/ghost/skills/run-workflow/SKILL.md @@ -0,0 +1,13 @@ +--- +description: Use Ghost to find, preview, start, and monitor governed business workflows. Use when the user asks Claude to perform an operation through Ghost or inspect a Ghost run. +--- + +# Run a governed Ghost workflow + +1. Use `list_workflows` to find the requested workflow. Do not guess an ID. +2. Use `get_workflow` and summarize the exact plan before starting it. +3. Ask the user to confirm that Ghost should start the run, then call `start_run`. +4. Poll `get_run` only when useful. If it reports `AWAITING_APPROVAL`, direct the user to the Ghost app. Never imply that approval can happen in Claude Code. +5. Report verification results and errors from Ghost without claiming success before the run reaches `SUCCEEDED`. + +Ghost is the execution and audit authority. Never attempt to bypass its approval gate or recreate a mutating workflow with shell/browser tools when the user selected Ghost.