Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

agent-identity.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

agent-identity.tsBlame476 lines · 1 contributor
a6d8fd5Claude1/**
2 * Block K2 — Agent identities + scoped permissions.
3 *
4 * Thin wrapper over Block H's marketplace.ts that lets the K-agent layer
5 * spin up auditable "bot" apps per agent kind. Every K-agent runs as an
6 * app with slug `agent-<kind>` and a matching `app_bots` row named
7 * `agent-<kind>[bot]`. Installations are per-repository, permissions are
8 * scoped at install time and revocable, and every token is minted through
9 * marketplace.ts's `ghi_`-prefixed install-token issuer.
10 *
11 * No new tables — K2 reuses H1/H2's `apps`, `app_installations`, `app_bots`,
12 * `app_install_tokens`, `app_events`.
13 *
14 * Design rules:
15 * - Do not duplicate primitives (hashing, token gen, bot username). Import
16 * from marketplace.ts.
17 * - Do not modify marketplace.ts. If we need extra vocabulary, layer it.
18 * - `AGENT_PERMISSIONS` is a superset of marketplace `KNOWN_PERMISSIONS`
19 * and adds `agent:invoke` so one agent can trigger another.
20 * - All DB helpers return null / false on error — never throw. This mirrors
21 * marketplace.ts's style and keeps callers out of try/catch ceremony.
22 */
23
24import { and, asc, eq, isNull } from "drizzle-orm";
25import { db } from "../db";
26import {
27 apps,
28 appBots,
29 appEvents,
30 appInstallations,
31 appInstallTokens,
32 users,
33 type App,
34 type AppInstallation,
35} from "../db/schema";
36import {
37 botUsername,
38 hasPermission,
39 hashBearer,
40 issueInstallToken,
41 KNOWN_PERMISSIONS,
42 verifyInstallToken,
43 type Permission,
44} from "./marketplace";
45
46// ---------------------------------------------------------------------------
47// Permission vocabulary
48// ---------------------------------------------------------------------------
49
50/**
51 * Vocabulary agents are allowed to request. Superset of marketplace
52 * `KNOWN_PERMISSIONS` + the new `agent:invoke` which lets one K-agent spawn
53 * another.
54 *
55 * Only the marketplace-known subset can be persisted through `createApp` /
56 * `installApp`. `agent:invoke` is stored by writing straight to `apps.permissions`
57 * / `app_installations.granted_permissions` via this module, bypassing the
58 * `normalisePermissions` filter in marketplace.ts (which would drop unknowns).
59 */
60export const AGENT_PERMISSIONS = [
61 "contents:read",
62 "contents:write",
63 "issues:read",
64 "issues:write",
65 "pulls:read",
66 "pulls:write",
67 "checks:read",
68 "checks:write",
69 "deployments:read",
70 "deployments:write",
71 "metadata:read",
72 "agent:invoke",
73] as const;
74
75export type AgentPermission = (typeof AGENT_PERMISSIONS)[number];
76
77/** Which of the agent perms are also understood by marketplace.ts. */
78const MARKETPLACE_PERMS: ReadonlySet<string> = new Set(
79 KNOWN_PERMISSIONS as readonly string[]
80);
81
82/** All agent slugs are prefixed with `agent-`. */
83export const AGENT_SLUG_PREFIX = "agent-";
84
85// ---------------------------------------------------------------------------
86// Pure helpers
87// ---------------------------------------------------------------------------
88
89/** Is this string a valid agent permission in our vocabulary? */
90export function isAgentPermission(p: string): p is AgentPermission {
91 return (AGENT_PERMISSIONS as readonly string[]).includes(p);
92}
93
94/** Drop unknowns, de-duplicate, preserve order of first appearance. */
95export function normaliseAgentPermissions(
96 input: readonly string[]
97): AgentPermission[] {
98 const seen = new Set<string>();
99 const out: AgentPermission[] = [];
100 for (const p of input) {
101 if (isAgentPermission(p) && !seen.has(p)) {
102 seen.add(p);
103 out.push(p);
104 }
105 }
106 return out;
107}
108
109/** Parse JSON permissions column, filtered to the agent vocabulary. */
110export function parseAgentPermissions(
111 raw: string | null | undefined
112): AgentPermission[] {
113 if (!raw) return [];
114 try {
115 const parsed = JSON.parse(raw);
116 if (!Array.isArray(parsed)) return [];
117 return normaliseAgentPermissions(parsed);
118 } catch {
119 return [];
120 }
121}
122
123/** Derive the canonical agent slug for a kind (e.g. "reviewer" → "agent-reviewer"). */
124export function agentSlug(kind: string): string {
125 const trimmed = (kind || "").toLowerCase().replace(/[^a-z0-9]+/g, "-");
126 const stripped = trimmed.replace(/^-+|-+$/g, "").slice(0, 32);
127 const base = stripped || "unknown";
128 return base.startsWith(AGENT_SLUG_PREFIX) ? base : AGENT_SLUG_PREFIX + base;
129}
130
131/** Guard: does this bot username belong to a K-agent? */
132export function isAgentBotUsername(name: string | null | undefined): boolean {
133 if (!name) return false;
134 return name.startsWith(AGENT_SLUG_PREFIX) && name.endsWith("[bot]");
135}
136
137// ---------------------------------------------------------------------------
138// DB helpers — defensive, null-returning on error.
139// ---------------------------------------------------------------------------
140
141/** Pick the oldest user as "system" for creator_id bootstrapping. */
142async function resolveSystemUserId(): Promise<string | null> {
143 try {
144 const [first] = await db
145 .select({ id: users.id })
146 .from(users)
147 .orderBy(asc(users.createdAt))
148 .limit(1);
149 return first?.id || null;
150 } catch {
151 return null;
152 }
153}
154
155/**
156 * Idempotent: ensure an app row with the given slug exists, plus its
157 * `app_bots` row. Returns the app, or null on failure (e.g. no users yet).
158 *
159 * `slug` is rewritten to have the `agent-` prefix if missing. Permissions
160 * are stored as the full agent vocabulary (including `agent:invoke`),
161 * bypassing marketplace's filter.
162 */
163export async function ensureAgentApp(
164 slug: string,
165 displayName: string,
166 permissions: readonly string[]
167): Promise<App | null> {
168 const finalSlug = slug.startsWith(AGENT_SLUG_PREFIX)
169 ? slug
170 : AGENT_SLUG_PREFIX + slug;
171 const perms = normaliseAgentPermissions(permissions);
172 try {
173 const [existing] = await db
174 .select()
175 .from(apps)
176 .where(eq(apps.slug, finalSlug))
177 .limit(1);
178 if (existing) return existing;
179 const creatorId = await resolveSystemUserId();
180 if (!creatorId) {
181 console.error(
182 "[agent-identity] ensureAgentApp: no users exist yet; cannot bootstrap"
183 );
184 return null;
185 }
186 const [row] = await db
187 .insert(apps)
188 .values({
189 slug: finalSlug,
190 name: displayName,
191 description: `K-agent: ${displayName}`,
192 creatorId,
193 permissions: JSON.stringify(perms),
194 defaultEvents: "[]",
195 isPublic: false,
196 })
197 .returning();
198 if (!row) return null;
199 // Matching bot account — unique on username + appId.
200 try {
201 await db.insert(appBots).values({
202 appId: row.id,
203 username: botUsername(finalSlug),
204 displayName: `${displayName} (agent bot)`,
205 });
206 } catch (err) {
207 // Duplicate from a prior partial run is fine; anything else is logged.
208 if (!String((err as Error)?.message || "").includes("duplicate")) {
209 console.error("[agent-identity] ensureAgentApp bot insert:", err);
210 }
211 }
212 return row;
213 } catch (err) {
214 console.error("[agent-identity] ensureAgentApp:", err);
215 return null;
216 }
217}
218
219/**
220 * Install an agent for a single repository. Idempotent — if a non-uninstalled
221 * installation exists, the granted permissions are overwritten to the new
222 * set. Audit trail is recorded via `app_events`.
223 *
224 * `grantedPermissions` is filtered through the agent vocabulary AND against
225 * the permissions the app originally declared — you cannot grant more than
226 * the agent asked for. Returns the installation row, or null on failure.
227 */
228export async function installAgentForRepo(
229 agentSlug: string,
230 repoId: string,
231 installerUserId: string,
232 grantedPermissions: readonly string[]
233): Promise<AppInstallation | null> {
234 try {
235 const [app] = await db
236 .select()
237 .from(apps)
238 .where(eq(apps.slug, agentSlug))
239 .limit(1);
240 if (!app) return null;
241 const appPerms = parseAgentPermissions(app.permissions);
242 const requested = normaliseAgentPermissions(grantedPermissions);
243 const filtered = requested.filter((p) => appPerms.includes(p));
244 const [existing] = await db
245 .select()
246 .from(appInstallations)
247 .where(
248 and(
249 eq(appInstallations.appId, app.id),
250 eq(appInstallations.targetType, "repository"),
251 eq(appInstallations.targetId, repoId),
252 isNull(appInstallations.uninstalledAt)
253 )
254 )
255 .limit(1);
256 if (existing) {
257 await db
258 .update(appInstallations)
259 .set({ grantedPermissions: JSON.stringify(filtered) })
260 .where(eq(appInstallations.id, existing.id));
261 try {
262 await db.insert(appEvents).values({
263 appId: app.id,
264 installationId: existing.id,
265 kind: "installed",
266 payload: JSON.stringify({ updated: true, agent: agentSlug }),
267 });
268 } catch {
269 /* audit best-effort */
270 }
271 return existing;
272 }
273 const [row] = await db
274 .insert(appInstallations)
275 .values({
276 appId: app.id,
277 installedBy: installerUserId,
278 targetType: "repository",
279 targetId: repoId,
280 grantedPermissions: JSON.stringify(filtered),
281 })
282 .returning();
283 if (row) {
284 try {
285 await db.insert(appEvents).values({
286 appId: app.id,
287 installationId: row.id,
288 kind: "installed",
289 payload: JSON.stringify({ agent: agentSlug, repoId }),
290 });
291 } catch {
292 /* audit best-effort */
293 }
294 }
295 return row || null;
296 } catch (err) {
297 console.error("[agent-identity] installAgentForRepo:", err);
298 return null;
299 }
300}
301
302/**
303 * Mint a short-lived bearer for an agent scoped to a single repo.
304 * Reuses marketplace.ts's `issueInstallToken` — we do not duplicate
305 * token generation here.
306 */
307export async function issueAgentToken(
308 agentSlug: string,
309 repoId: string,
310 ttlSeconds = 3600
311): Promise<{ token: string; expiresAt: Date } | null> {
312 try {
313 const [app] = await db
314 .select()
315 .from(apps)
316 .where(eq(apps.slug, agentSlug))
317 .limit(1);
318 if (!app) return null;
319 const [install] = await db
320 .select()
321 .from(appInstallations)
322 .where(
323 and(
324 eq(appInstallations.appId, app.id),
325 eq(appInstallations.targetType, "repository"),
326 eq(appInstallations.targetId, repoId),
327 isNull(appInstallations.uninstalledAt)
328 )
329 )
330 .limit(1);
331 if (!install) return null;
332 return await issueInstallToken(install.id, ttlSeconds);
333 } catch (err) {
334 console.error("[agent-identity] issueAgentToken:", err);
335 return null;
336 }
337}
338
339/** What `verifyAgentToken` returns when a token is valid + agent-shaped. */
340export interface AgentTokenContext {
341 agentSlug: string;
342 repoId: string;
343 botUsername: string;
344 permissions: AgentPermission[];
345 installationId: string;
346}
347
348/**
349 * Verify a bearer. Returns null when:
350 * - the token is not `ghi_`-prefixed,
351 * - the underlying install-token is unknown / expired / revoked / uninstalled,
352 * - the bot behind the token is not an `agent-*` bot.
353 */
354export async function verifyAgentToken(
355 token: string
356): Promise<AgentTokenContext | null> {
357 if (!token || !token.startsWith("ghi_")) return null;
358 const ctx = await verifyInstallToken(token);
359 if (!ctx) return null;
360 if (!isAgentBotUsername(ctx.botUsername)) return null;
361 if (ctx.installation.targetType !== "repository") return null;
362 return {
363 agentSlug: ctx.app.slug,
364 repoId: ctx.installation.targetId,
365 botUsername: ctx.botUsername,
366 permissions: parseAgentPermissions(ctx.installation.grantedPermissions),
367 installationId: ctx.installation.id,
368 };
369}
370
371/**
372 * Verify + assert a required permission. Returns the context on success,
373 * throws `Error` on failure. Handlers should wrap with try/catch and map
374 * to a 401 / 403 response.
375 */
376export async function requireAgentPermission(
377 token: string,
378 permission: AgentPermission | string
379): Promise<AgentTokenContext> {
380 const ctx = await verifyAgentToken(token);
381 if (!ctx) throw new Error("agent token invalid or expired");
382 if (!hasPermission(ctx.permissions as readonly string[], permission)) {
383 throw new Error(`agent token missing permission: ${permission}`);
384 }
385 return ctx;
386}
387
388/** Revoke a single token by its stored hash. Idempotent. */
389export async function revokeAgentToken(tokenHash: string): Promise<boolean> {
390 if (!tokenHash) return false;
391 try {
392 const [row] = await db
393 .update(appInstallTokens)
394 .set({ revokedAt: new Date() })
395 .where(
396 and(
397 eq(appInstallTokens.tokenHash, tokenHash),
398 isNull(appInstallTokens.revokedAt)
399 )
400 )
401 .returning();
402 return !!row;
403 } catch (err) {
404 console.error("[agent-identity] revokeAgentToken:", err);
405 return false;
406 }
407}
408
409/** Convenience: hash + revoke in one go (when you only have the raw token). */
410export async function revokeAgentTokenByRaw(token: string): Promise<boolean> {
411 if (!token) return false;
412 return revokeAgentToken(hashBearer(token));
413}
414
415/**
416 * Soft-uninstall an agent for a repo. Sets `uninstalledAt` and revokes any
417 * outstanding tokens for that installation. Idempotent — returns false if
418 * no matching install was found.
419 */
420export async function uninstallAgent(
421 agentSlug: string,
422 repoId: string
423): Promise<boolean> {
424 try {
425 const [app] = await db
426 .select()
427 .from(apps)
428 .where(eq(apps.slug, agentSlug))
429 .limit(1);
430 if (!app) return false;
431 const [updated] = await db
432 .update(appInstallations)
433 .set({ uninstalledAt: new Date() })
434 .where(
435 and(
436 eq(appInstallations.appId, app.id),
437 eq(appInstallations.targetType, "repository"),
438 eq(appInstallations.targetId, repoId),
439 isNull(appInstallations.uninstalledAt)
440 )
441 )
442 .returning();
443 if (!updated) return false;
444 // Revoke every live token.
445 try {
446 await db
447 .update(appInstallTokens)
448 .set({ revokedAt: new Date() })
449 .where(
450 and(
451 eq(appInstallTokens.installationId, updated.id),
452 isNull(appInstallTokens.revokedAt)
453 )
454 );
455 } catch {
456 /* best-effort */
457 }
458 try {
459 await db.insert(appEvents).values({
460 appId: app.id,
461 installationId: updated.id,
462 kind: "uninstalled",
463 payload: JSON.stringify({ agent: agentSlug, repoId }),
464 });
465 } catch {
466 /* audit best-effort */
467 }
468 return true;
469 } catch (err) {
470 console.error("[agent-identity] uninstallAgent:", err);
471 return false;
472 }
473}
474
475// Types re-exported for callers that only import from this module.
476export type { Permission };