@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
230 lines (229 loc) • 11.5 kB
JavaScript
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;
});
}