-
Notifications
You must be signed in to change notification settings - Fork 0
Add Ghost-issued revocable agent credentials, Settings UI, and Claude MCP plugin #372
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 }); | ||
| } |
| 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({ | ||
| 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 }); | ||
| } | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 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> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a user creates or revokes an agent credential, these routes only mutate
AgentCredentialand never append anAuditEvent; 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 👍 / 👎.