UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

230 lines (229 loc) • 11.5 kB
import { tryFileLock } from "../governance/locks.js"; import { lstat, mkdir, readFile } from "node:fs/promises"; import path from "node:path"; import { dump, load } from "js-yaml"; import { z } from "zod"; import { atomic, BridgeError, bridgeRoot, computerName, PERMISSION_MODES } from "./protocol.js"; import { readComputers, resolveComputer } from "./computer-identity.js"; const grantAction = z.enum(["dispatch", "hand_off"]); const grantScope = z.union([ z.literal("global"), z.string().regex(/^project:[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/), ]); export const grantSchema = z.object({ scope: grantScope.describe("global, or project:<slug> for one project."), actions: z.array(grantAction).min(1).max(2).describe("Which conductor actions this grant covers."), computers: z.array(computerName).min(1).max(32).optional().describe("Named verified peers; omitted means any."), until: z.string().datetime({ offset: true }).optional().describe("Expiry timestamp; omitted means until revoked."), maxPermissionMode: z.enum(PERMISSION_MODES).optional().describe("Highest permission mode an agent may start a dispatched worker in; omitted means auto (never full-access)."), }).strict(); const grantFile = (root = bridgeRoot()) => path.join(root, "conductor.yaml"); const fileSchema = z.object({ grants: z.array(grantSchema).max(64) }).strict(); async function regularPrivateFile(file) { const info = await lstat(file).catch(() => undefined); if (!info) return false; if (!info.isFile() || info.isSymbolicLink() || info.size > 65_536 || (info.mode & 0o077)) { throw new BridgeError(409, "conductor.yaml must be a private regular file (0600), at most 64 KiB."); } return true; } /** js-yaml parses an unquoted timestamp as a Date; keep the schema string-only * and accept the bare `until: 2026-10-01T00:00Z` form from the docs. */ function normalizeGrants(raw) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw; const file = { ...raw }; if (Array.isArray(file.grants)) { file.grants = file.grants.map(item => { if (!item || typeof item !== "object" || Array.isArray(item)) return item; const grant = { ...item }; if (grant.until instanceof Date) grant.until = grant.until.toISOString(); else if (typeof grant.until === "string") { const parsed = Date.parse(grant.until); if (!Number.isNaN(parsed)) grant.until = new Date(parsed).toISOString(); } return grant; }); } return file; } export async function listGrants(root = bridgeRoot()) { const file = grantFile(root); if (!await regularPrivateFile(file)) return []; const parsed = fileSchema.safeParse(normalizeGrants(load(await readFile(file, "utf8")))); if (!parsed.success) throw new BridgeError(409, "conductor.yaml does not match the grants schema."); const now = Date.now(); return parsed.data.grants.filter(grant => !grant.until || Date.parse(grant.until) > now); } async function writeGrants(root, grants) { const file = grantFile(root); await mkdir(root, { recursive: true, mode: 0o700 }); await atomic(file, dump({ grants }, { lineWidth: 120 })); } /** A grant's computer list names `computer` itself or another name of the same computer. */ function namesComputer(list, computer, known) { if (list.includes(computer)) return true; const row = known && resolveComputer(known, computer); return !!row && list.some(name => resolveComputer(known, name) === row); } /** Project scope beats global; a named computer list beats any. */ function specificity(grant) { return (grant.scope === "global" ? 0 : 2) + (grant.computers ? 1 : 0); } /** Most specific matching grant wins. A grant's computers may be any name the * destination answers to (see `computer-identity.ts`). A computers-restricted grant never * covers an unresolved destination: `anywhere` may pick a peer outside the * list, and a local hand-off never goes to one of them. */ export function matchGrant(grants, query, now = Date.now()) { const matches = grants.filter(grant => { if (!grant.actions.includes(query.action)) return false; if (grant.until && Date.parse(grant.until) <= now) return false; if (grant.scope !== "global") { const slug = grant.scope.slice("project:".length); if (query.project !== slug) return false; } if (grant.computers && (!query.computer || query.computer === "anywhere" || !namesComputer(grant.computers, query.computer, query.computers))) return false; return true; }); return matches.sort((a, b) => specificity(b) - specificity(a))[0]; } /** matchGrant over the stored grants; the known computers are read once, and * only when a grant restricts computers. */ export async function findGrant(query, root = bridgeRoot()) { const grants = await listGrants(root); const restricted = query.computer && query.computer !== "anywhere" && grants.some(grant => grant.computers); return matchGrant(grants, { ...query, ...(restricted ? { computers: (await readComputers({ root, trusted: true })).computers } : {}) }); } /** The grant with each computer named by its canonical name; unknown names stay. */ function nameComputers(grant, known) { if (!grant.computers) return grant; return { ...grant, computers: [...new Set(grant.computers.map(name => resolveComputer(known, name)?.name ?? name))] }; } async function knownFor(root, grants) { return grants.some(grant => grant.computers) ? (await readComputers({ root, trusted: true })).computers : []; } /** Grants as listed to people: computers by canonical name. The file is left as written. */ export async function listNamedGrants(root = bridgeRoot()) { const grants = await listGrants(root); const known = await knownFor(root, grants); return grants.map(grant => nameComputers(grant, known)); } /** The highest permission mode an agent may ask for under a grant. Without a * grant, or with one that names no ceiling, that is auto: full-access needs a * grant that says so. */ export const DEFAULT_MAX_PERMISSION_MODE = "auto"; export function permissionModeAllowed(mode, grant) { const ceiling = grant?.maxPermissionMode ?? DEFAULT_MAX_PERMISSION_MODE; return PERMISSION_MODES.indexOf(mode) <= PERMISSION_MODES.indexOf(ceiling); } export function grantLabel(grant) { return grant.scope; } const mutations = new Map(); async function mutateGrants(root, action) { const key = path.resolve(root); const previous = mutations.get(key) ?? Promise.resolve(); const pending = previous.catch(() => { }).then(async () => { const release = tryFileLock(grantFile(key)); if (!release) throw new BridgeError(409, "Conductor grants are being updated. Try again."); try { return await action(); } finally { release(); } }); mutations.set(key, pending); try { return await pending; } finally { if (mutations.get(key) === pending) mutations.delete(key); } } export async function addGrant(input, root = bridgeRoot()) { return mutateGrants(root, async () => { const parsed = grantSchema.parse(input); const existing = await listGrants(root); const known = await knownFor(root, [parsed, ...existing]); const grant = nameComputers(parsed, known); if (existing.some(item => canonicalGrant(nameComputers(item, known)) === canonicalGrant(grant))) { throw new BridgeError(409, "That grant is already listed."); } if (existing.length >= 64) throw new BridgeError(409, "conductor.yaml already holds 64 grants."); await writeGrants(root, [...existing, grant]); return grant; }); } function canonicalGrant(grant) { return JSON.stringify([grant.scope, [...grant.actions].sort(), grant.computers ? [...grant.computers].sort() : null, grant.until ?? null]); } /** Write a grant from an approval card answer: already listed is success. */ export async function ensureGrant(input, root = bridgeRoot()) { return mutateGrants(root, async () => { const parsed = grantSchema.parse(input); const existing = await listGrants(root); const known = await knownFor(root, [parsed, ...existing]); const grant = nameComputers(parsed, known); if (existing.some(item => canonicalGrant(nameComputers(item, known)) === canonicalGrant(grant))) return grant; if (existing.length >= 64) throw new BridgeError(409, "conductor.yaml already holds 64 grants."); await writeGrants(root, [...existing, grant]); return grant; }); } export async function removeGrant(input, root = bridgeRoot()) { return mutateGrants(root, async () => { const body = z.object({ index: z.number().int().min(0).max(63).optional(), expected: grantSchema.optional(), scope: grantScope.optional(), actions: z.array(grantAction).min(1).max(2).optional(), computers: z.array(computerName).min(1).max(32).optional(), until: z.string().datetime({ offset: true }).optional(), }).strict().refine(value => value.index !== undefined || value.scope !== undefined, { message: "Provide an index or a scope to remove." }).parse(input); const existing = await listGrants(root); if (!existing.length) throw new BridgeError(404, "There are no conductor grants to remove."); // The list shows canonical names, so compare that way, not by the raw text. const known = await knownFor(root, [body, body.expected ?? {}, ...existing]); const named = (grant) => nameComputers(grant, known); const wanted = body.computers && named({ scope: "global", actions: ["dispatch"], computers: body.computers }).computers; let index = body.index; if (index === undefined) { // Scope names the row; the optional fields refine it. They never invent a // default actions list, which would miss a single-action grant. const sameSet = (a, b) => a.length === b.length && [...a].sort().join() === [...b].sort().join(); index = existing.findIndex(grant => grant.scope === body.scope && (body.actions === undefined || sameSet(grant.actions, body.actions)) && (wanted === undefined || (grant.computers !== undefined && sameSet(named(grant).computers, wanted))) && (body.until === undefined || grant.until === body.until)); if (index < 0) throw new BridgeError(404, "No conductor grant matches that scope."); } if (index >= existing.length) throw new BridgeError(404, "No conductor grant has that index."); if (body.expected && canonicalGrant(named(existing[index])) !== canonicalGrant(named(body.expected))) { throw new BridgeError(409, "The grants list changed. Refresh it before revoking this grant."); } const [removed] = existing.splice(index, 1); await writeGrants(root, existing); return removed; }); }