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
12 changes: 5 additions & 7 deletions cloud/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
12 changes: 7 additions & 5 deletions cloud/apps/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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):
* {
Expand All @@ -18,7 +17,7 @@
* "cwd": "<repo>/cloud",
* "env": {
* "GHOST_API_URL": "http://localhost:3000",
* "GHOST_AGENT_API_KEY": "…"
* "GHOST_ACCESS_TOKEN": "ghost_agent_…"
* }
* }
* }
Expand Down Expand Up @@ -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<string, unknown> };
const p = (params ?? {}) as {
name?: string;
arguments?: Record<string, unknown>;
};
const name = typeof p.name === "string" ? p.name : "";
if (!name) return fail(id, -32602, "tools/call requires name");
try {
Expand All @@ -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 });

Expand Down
21 changes: 17 additions & 4 deletions cloud/apps/web/src/app/(app)/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
Expand All @@ -21,7 +25,9 @@ export default async function SettingsPage() {
<div className="mx-auto max-w-2xl space-y-6">
<div>
<h1 className="text-xl font-semibold">Settings</h1>
<p className="mt-1 text-sm text-[var(--color-muted)]">Organization and members.</p>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Organization and members.
</p>
</div>

<Card>
Expand All @@ -46,13 +52,20 @@ export default async function SettingsPage() {
</CardHeader>
<CardBody className="space-y-2">
{(org?.memberships ?? []).map((m) => (
<div key={m.id} className="flex items-center justify-between text-sm">
<div
key={m.id}
className="flex items-center justify-between text-sm"
>
<span>{m.user.email ?? m.user.name ?? m.userId}</span>
<span className="text-xs text-[var(--color-muted)]">{m.role}</span>
<span className="text-xs text-[var(--color-muted)]">
{m.role}
</span>
</div>
))}
</CardBody>
</Card>

<AgentCredentials />
</div>
);
}
2 changes: 1 addition & 1 deletion cloud/apps/web/src/app/api/agent/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 });
}
56 changes: 56 additions & 0 deletions cloud/apps/web/src/app/api/settings/agent-credentials/route.ts
Original file line number Diff line number Diff line change
@@ -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({
Comment on lines +44 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record credential lifecycle in the audit log

When a user creates or revokes an agent credential, these routes only mutate AgentCredential and never append an AuditEvent; consequently, credential issuance and revocation are absent from the hash-chained organizational audit trail, making security investigations unable to verify these sensitive lifecycle actions. Record both POST and DELETE operations in the audit chain, ideally atomically with their credential mutations.

AGENTS.md reference: AGENTS.md:L101-L105

Useful? React with 👍 / 👎.

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 });
}
126 changes: 126 additions & 0 deletions cloud/apps/web/src/components/settings/agent-credentials.tsx
Original file line number Diff line number Diff line change
@@ -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<Credential[]>([]);
const [name, setName] = useState("Claude Code");
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<Card>
<CardHeader>
<CardTitle>Claude Code and agent access</CardTitle>
</CardHeader>
<CardBody className="space-y-4 text-sm">
<p className="text-[var(--color-muted)]">
Create a revocable Ghost credential for the Claude Code plugin or
another MCP client. Agents can propose runs, but approval remains in
Ghost.
</p>
<div className="flex gap-2">
<input
aria-label="Credential name"
className="min-w-0 flex-1 rounded-md border border-[var(--color-border)] bg-transparent px-3 py-2"
maxLength={80}
onChange={(event) => setName(event.target.value)}
value={name}
/>
<Button disabled={!name.trim()} onClick={createCredential}>
Create credential
</Button>
Comment on lines +85 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable credential creation while the request is pending

On a slow connection, double-clicking this still-enabled button sends concurrent POST requests and creates multiple valid credentials. Because each completion overwrites the single token state, only one plaintext remains visible while the other active credential's secret is irretrievably lost, forcing the user to identify and revoke the orphan. Track the pending request and disable the create control until it completes.

Useful? React with 👍 / 👎.

</div>
{token && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
<p className="font-medium">
Copy this credential now. Ghost will not show it again.
</p>
<code className="mt-2 block break-all select-all text-xs">
{token}
</code>
</div>
)}
{error && <p className="text-red-500">{error}</p>}
<div className="space-y-2">
{credentials.map((credential) => (
<div
className="flex items-center justify-between gap-3 rounded-md border border-[var(--color-border)] p-3"
key={credential.id}
>
<div>
<p className="font-medium">{credential.name}</p>
<p className="font-mono text-xs text-[var(--color-muted)]">
{credential.tokenHint}
</p>
</div>
<Button variant="secondary" onClick={() => revoke(credential.id)}>
Revoke
</Button>
</div>
))}
{!credentials.length && (
<p className="text-[var(--color-muted)]">
No active agent credentials.
</p>
)}
</div>
</CardBody>
</Card>
);
}
Loading