/** * Persistence for {@link AgentGrant} records. * * A single JSON file (`~/.mailpouch-agents.json`) holds every grant the * server has ever seen, across all three statuses. Writes are atomic via * tmp→rename, consistent with the rest of the project's credential-hygiene * story. The file is mode 0600. * * We deliberately keep this in memory as a Map and * flush the whole file on every mutation. n is small (<100 grants even in * aggressive use), JSON serialization is tens of μs, and atomic writes * eliminate the need for a lockfile. */ import { readFileSync, writeFileSync, existsSync, renameSync } from "fs"; import { randomBytes } from "crypto"; import type { AgentGrant, AgentGrantStatus, GrantConditions } from "./types.js"; import type { PermissionPreset, ToolName } from "../config/schema.js"; import { logger } from "../utils/logger.js"; import { notifications } from "./notifications.js"; import { withFileLock } from "../utils/file-lock.js"; interface StoreFile { version: 1; grants: AgentGrant[]; } export interface CreatePendingArgs { clientId: string; clientName: string; /** IP the agent registered from (for display on the approval card). */ registeredFromIp?: string; } export interface ApproveArgs { clientId: string; preset: PermissionPreset; toolOverrides?: Partial>; conditions?: GrantConditions; note?: string; } /** * XPORT-021: ceiling on simultaneously-pending (not-yet-reviewed) grants. The * DCR endpoint creates a pending grant per registration; a per-IP rate limiter * caps the burst but a slow flood could still grow the settings-UI grant list * without bound. When the cap is reached we evict the oldest pending grant * before admitting a new one, so the review queue stays usefully small. */ const MAX_PENDING_GRANTS = 50; export class AgentGrantStore { private grants = new Map(); private readonly path: string; constructor(path: string) { this.path = path; this.load(); } private load(): void { if (!existsSync(this.path)) return; try { const raw = readFileSync(this.path, "utf-8"); const parsed = JSON.parse(raw) as Partial; const list = Array.isArray(parsed.grants) ? parsed.grants : []; for (const g of list) { if (g && typeof g.clientId === "string") { this.grants.set(g.clientId, g); } } } catch (err) { logger.warn(`AgentGrantStore: failed to parse ${this.path}, starting empty`, "AgentGrantStore", err); this.grants.clear(); } } /** * PERM-006: run a structural mutation under a cross-process advisory lock. * Both the MCP server and the settings server hold their own AgentGrantStore * instance over the same file; without locking + a reload-merge, one * process's whole-file rewrite silently drops grants the other process * created or modified. We reload the on-disk state under the lock, MERGE the * caller's pending in-memory record set on top (so a fresh local `set` made * by `fn` before persist isn't lost), apply `fn`, then persist atomically. */ private mutate(fn: () => T): T { return withFileLock(this.path, () => { this.reloadMerge(); return fn(); }); } /** * Reload the file from disk under the lock so on-disk state is authoritative * before we mutate + persist. This both recovers grants another process * created (the PERM-006 lost-grant case) AND refreshes the *status* of grants * we already hold — without this, a grant that process A approved/revoked * while we held a stale `pending` copy would be silently reverted by our * whole-file `persist()`. The one thing the disk is NOT authoritative for is * the call counters (`totalCalls`/`lastCallAt`): `recordCall` bumps those in * memory and defers the fsync, so if our in-memory count is ahead we carry it * forward onto the disk record rather than losing the unflushed increments. */ private reloadMerge(): void { if (!existsSync(this.path)) return; try { const parsed = JSON.parse(readFileSync(this.path, "utf-8")) as Partial; const list = Array.isArray(parsed.grants) ? parsed.grants : []; const seen = new Set(); for (const g of list) { if (!g || typeof g.clientId !== "string") continue; seen.add(g.clientId); const mine = this.grants.get(g.clientId); if (mine && mine.totalCalls > (g.totalCalls ?? 0)) { // Preserve our not-yet-flushed call counters; disk wins for everything else. this.grants.set(g.clientId, { ...g, totalCalls: mine.totalCalls, lastCallAt: mine.lastCallAt }); } else { this.grants.set(g.clientId, g); } } // Drop in-memory records that no longer exist on disk (pruned by a peer), // but keep any we created this session that haven't been persisted yet // (none, given mutate() persists under the lock — defensive only). for (const clientId of [...this.grants.keys()]) { if (!seen.has(clientId)) this.grants.delete(clientId); } } catch (err) { logger.warn(`AgentGrantStore: reloadMerge failed for ${this.path}`, "AgentGrantStore", err); } } private persist(): void { const payload: StoreFile = { version: 1, grants: [...this.grants.values()] }; // tmp MUST be on the same filesystem as the destination for rename(2) to // be atomic. On Linux installs where /tmp is tmpfs and $HOME is on // separate storage, using os.tmpdir() fails with EXDEV. Put the tmp // next to the destination instead. const tmp = `${this.path}.${randomBytes(8).toString("hex")}.tmp`; writeFileSync(tmp, JSON.stringify(payload, null, 2), { encoding: "utf-8", mode: 0o600 }); renameSync(tmp, this.path); } /** * Record a brand-new DCR client as a pending grant. Called from the OAuth * DCR handler; idempotent when the same client_id is registered twice (e.g. * an MCP host retrying on a disconnected tunnel). */ createPending(args: CreatePendingArgs): AgentGrant { return this.mutate(() => { const existing = this.grants.get(args.clientId); if (existing) return existing; // XPORT-021: bound the pending-review queue. Evict the oldest pending // grant(s) (by createdAt) once we'd exceed the cap so a registration // flood can't grow the settings-UI list unboundedly. const pending = [...this.grants.values()] .filter(g => g.status === "pending") .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); for (let i = 0; i <= pending.length - MAX_PENDING_GRANTS; i++) { this.grants.delete(pending[i].clientId); } const grant: AgentGrant = { clientId: args.clientId, clientName: args.clientName || "(unnamed client)", status: "pending", preset: "read_only", // placeholder — replaced on approve createdAt: new Date().toISOString(), totalCalls: 0, ...(args.registeredFromIp ? { registeredFromIp: args.registeredFromIp } : {}), }; this.grants.set(args.clientId, grant); this.persist(); notifications.emitGrantChanged("grant-created", grant); return grant; }); } /** * Ensure an *active* grant exists for a service account (client_credentials). * Unlike the interactive flow (createPending → approve), a service account is * pre-approved out-of-band at issuance, so its grant is born active. Called at * startup for each persisted service account; idempotent — refreshes the * preset/conditions/name on an existing grant and (re-)activates it, so an * operator re-issuing or editing an account converges the grant. */ ensureActiveServiceGrant(args: { clientId: string; clientName: string; preset: PermissionPreset; conditions?: GrantConditions; }): AgentGrant { return this.mutate(() => { const now = new Date().toISOString(); const existing = this.grants.get(args.clientId); const grant: AgentGrant = { clientId: args.clientId, clientName: args.clientName || "(service account)", status: "active", preset: args.preset, conditions: args.conditions, createdAt: existing?.createdAt ?? now, approvedAt: existing?.approvedAt ?? now, totalCalls: existing?.totalCalls ?? 0, transport: "http", note: "service account (client_credentials)", }; // No-op re-verify of an already-active service grant: this runs on EVERY // client_credentials login, so re-emitting a "grant-approved" event here // spammed a notification/toast on each re-auth (e.g. cowork reconnecting). // Only notify on a real transition — first creation, or (re)activation of // a grant that wasn't already active. const wasActive = existing?.status === "active"; this.grants.set(args.clientId, grant); this.persist(); if (!existing) notifications.emitGrantChanged("grant-created", grant); else if (!wasActive) notifications.emitGrantChanged("grant-approved", grant); return grant; }); } /** * Record live connection info captured at the MCP `initialize` handshake onto * an existing grant (display-only; does NOT change status or identity). No-op * if the clientId has no grant — an unregistered caller is handled by the * grant gate, not here. */ recordConnection( clientId: string, info: { mcpClientName?: string; mcpClientVersion?: string; transport?: "http" | "stdio"; registeredFromIp?: string }, ): AgentGrant | null { return this.mutate(() => { const g = this.grants.get(clientId); if (!g) return null; if (info.mcpClientName) g.mcpClientName = info.mcpClientName; if (info.mcpClientVersion) g.mcpClientVersion = info.mcpClientVersion; if (info.transport) g.transport = info.transport; if (info.registeredFromIp && !g.registeredFromIp) g.registeredFromIp = info.registeredFromIp; g.lastConnectedAt = new Date().toISOString(); this.persist(); return g; }); } approve(args: ApproveArgs): AgentGrant | null { return this.mutate(() => { const g = this.grants.get(args.clientId); if (!g) return null; g.status = "active"; g.preset = args.preset; g.toolOverrides = args.toolOverrides; g.conditions = args.conditions; g.note = args.note; g.approvedAt = new Date().toISOString(); g.revokedAt = undefined; this.persist(); notifications.emitGrantChanged("grant-approved", g); return g; }); } deny(clientId: string, note?: string): AgentGrant | null { return this.mutate(() => { const g = this.grants.get(clientId); if (!g) return null; const wasPending = g.status === "pending"; g.status = "revoked"; g.revokedAt = new Date().toISOString(); g.note = note ?? g.note; this.persist(); // Distinguish "deny" (never-approved pending grant rejected) from // "revoke" (previously-approved grant taken back) for UI filtering. notifications.emitGrantChanged(wasPending ? "grant-denied" : "grant-revoked", g); return g; }); } revoke(clientId: string): AgentGrant | null { return this.deny(clientId); } /** * Mark a grant as expired in-place. Called by the gate when it observes * expiresAt has passed; atomic so concurrent tool calls agree on the state. */ markExpired(clientId: string): AgentGrant | null { return this.mutate(() => { const g = this.grants.get(clientId); if (!g) return null; if (g.status === "expired") return g; g.status = "expired"; this.persist(); notifications.emitGrantChanged("grant-expired", g); return g; }); } /** * Delete pending grants whose approval window (`maxAgeMs` since createdAt) has * elapsed — the auth request expires if the user doesn't approve in time, so * the agent must connect/auth again. Emits `grant-expired` for each (which * revokes any issued token) and removes the record. Returns the count expired. */ expireStalePending(maxAgeMs: number): number { return this.mutate(() => { const now = Date.now(); const stale = [...this.grants.values()].filter( (g) => g.status === "pending" && now - Date.parse(g.createdAt) > maxAgeMs, ); if (stale.length === 0) return 0; for (const g of stale) this.grants.delete(g.clientId); this.persist(); // Emit after the delete + persist so token revocation (the grant-expired // subscriber) acts on the now-removed grant's clientId. for (const g of stale) notifications.emitGrantChanged("grant-expired", g); return stale.length; }); } /** Record a successful tool call against the grant's counters. */ recordCall(clientId: string): void { const g = this.grants.get(clientId); if (!g) return; g.lastCallAt = new Date().toISOString(); g.totalCalls += 1; // Only persist periodically-ish to avoid fsync on every single tool // call. We flush on every mutation anyway when the grant *changes*; // this method deliberately does NOT persist so hot paths stay cheap. // PERM-010: index.ts now calls flushCounters() on a 5-minute interval and // again in gracefulShutdown, so these in-memory increments survive restart. } /** Force-flush any in-memory call-count updates to disk. */ flushCounters(): void { // Go through mutate() so we reload + reconcile on-disk status first; a bare // persist() here would blind-write our whole map and could revert a status // change a peer process made (the same hazard reloadMerge guards against). this.mutate(() => this.persist()); } get(clientId: string): AgentGrant | undefined { return this.grants.get(clientId); } list(filter?: { status?: AgentGrantStatus }): AgentGrant[] { const rows = [...this.grants.values()]; if (filter?.status) return rows.filter(g => g.status === filter.status); return rows; } /** Drop revoked/expired grants older than `retainDays` days. */ prune(retainDays = 90, now = Date.now()): number { return this.mutate(() => { const cutoff = now - retainDays * 24 * 60 * 60_000; let removed = 0; for (const [k, g] of this.grants) { if (g.status !== "revoked" && g.status !== "expired") continue; const endAt = g.revokedAt ?? g.approvedAt ?? g.createdAt; if (Date.parse(endAt) < cutoff) { this.grants.delete(k); removed++; } } if (removed > 0) this.persist(); return removed; }); } }