@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
227 lines (226 loc) • 12.4 kB
JavaScript
import { tryFileLock } from "../governance/locks.js";
import { randomUUID } from "node:crypto";
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, PERMISSION_MODES } from "./protocol.js";
/**
* The owner's release authority policy: per project, which release-type
* actions a conductor may send a worker to do on its own (`go`) and which need
* the owner's word each time (`ask`). It only restricts: nothing here lifts a
* worker's own permission checks. See docs/authority.md.
*/
export const RELEASE_ACTIONS = ["merge", "publish", "deploy", "app-store", "github-admin"];
export const releaseAction = z.enum(RELEASE_ACTIONS);
const verdict = z.enum(["go", "ask"]);
const projectSlug = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/);
const writer = z.enum(["phone", "cli"]);
export const projectEntrySchema = z.object({
default: verdict.optional().describe("go or ask for the release actions `actions` does not list; go when omitted."),
actions: z.object({
merge: verdict.optional(), publish: verdict.optional(), deploy: verdict.optional(),
"app-store": verdict.optional(), "github-admin": verdict.optional(),
}).strict().optional(),
maxPermissionMode: z.enum(PERMISSION_MODES).optional()
.describe("Highest permission mode an agent may start a worker in here; auto-edits when omitted and any action is ask."),
note: z.string().min(1).max(300).refine(value => !/[\x00-\x1f\x7f]/.test(value)).optional(),
}).strict();
const fileSchema = z.object({
projects: z.record(projectSlug, projectEntrySchema).refine(value => Object.keys(value).length <= 256, "At most 256 projects."),
updatedAt: z.string().datetime({ offset: true }).optional(),
updatedBy: writer.optional(),
}).strict();
/** Used until the owner first writes the policy: no project is restricted. */
export const DEFAULT_AUTHORITY = { projects: {} };
/** The permission ceiling of a project with any ask-first action that names none. */
export const ASK_FIRST_PERMISSION_MODE = "auto-edits";
const authorityFile = (root = bridgeRoot()) => path.join(root, "authority.yaml");
const confirmationsFile = (root = bridgeRoot()) => path.join(root, "authority-confirmations.json");
async function privateFile(file, name) {
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, `${name} must be a private regular file (0600), at most 64 KiB.`);
}
return true;
}
/** js-yaml reads an unquoted timestamp as a Date; the schema takes strings. */
function normalize(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw))
return raw;
const file = { ...raw };
if (file.updatedAt instanceof Date)
file.updatedAt = file.updatedAt.toISOString();
return file;
}
/** The saved policy, or the built-in defaults when the owner has written none.
* A file that is not private or does not parse is an error, never "no policy". */
export async function readAuthority(root = bridgeRoot()) {
const file = authorityFile(root);
if (!await privateFile(file, "authority.yaml"))
return { ...structuredClone(DEFAULT_AUTHORITY), source: "defaults" };
const parsed = fileSchema.safeParse(normalize(load(await readFile(file, "utf8")) ?? { projects: {} }));
if (!parsed.success)
throw new BridgeError(409, "authority.yaml does not match the release authority schema. Fix it with `phren authority set` or remove the broken entry.");
return { ...parsed.data, source: "file" };
}
export function projectAuthority(policy, project) {
const entry = Object.hasOwn(policy.projects, project) ? policy.projects[project] : undefined;
const verdictOf = (action) => entry?.actions?.[action] ?? entry?.default ?? "go";
const go = RELEASE_ACTIONS.filter(action => verdictOf(action) === "go");
const ask = RELEASE_ACTIONS.filter(action => verdictOf(action) === "ask");
const maxPermissionMode = entry?.maxPermissionMode ?? (ask.length ? ASK_FIRST_PERMISSION_MODE : undefined);
const parts = [
...(!entry ? ["no release restrictions listed"] : []),
...(entry && go.length ? [`go for ${go.join(", ")}`] : []),
...(ask.length ? [`ask-first for ${ask.join(", ")}`] : []),
...(maxPermissionMode ? [`dispatched workers start at most in ${maxPermissionMode}`] : []),
];
const line = `Release authority for ${project} (owner policy): ${parts.join("; ")}.${entry?.note ? ` Note: ${entry.note}` : ""}`;
return { project, listed: !!entry, go, ask, ...(maxPermissionMode ? { maxPermissionMode } : {}), ...(entry?.note ? { note: entry.note } : {}), line };
}
/** The lower of two permission ceilings; undefined is no ceiling. */
export function lowerMode(a, b) {
if (!a || !b)
return a ?? b;
return PERMISSION_MODES.indexOf(a) <= PERMISSION_MODES.indexOf(b) ? a : b;
}
const mutations = new Map();
/** One change at a time in this process, and a file lock across processes. */
async function mutate(file, action) {
const key = path.resolve(file);
const previous = mutations.get(key) ?? Promise.resolve();
const pending = previous.catch(() => { }).then(async () => {
await mkdir(path.dirname(key), { recursive: true, mode: 0o700 });
const release = tryFileLock(key);
if (!release)
throw new BridgeError(409, "The release authority policy is 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);
}
}
async function writeAuthority(root, projects, by) {
const sorted = Object.fromEntries(Object.entries(projects).sort(([a], [b]) => a.localeCompare(b)));
const file = fileSchema.parse({ projects: sorted, updatedAt: new Date().toISOString(), updatedBy: by });
await atomic(authorityFile(root), dump(file, { lineWidth: 120 }));
}
export const setAuthoritySchema = projectEntrySchema.extend({ project: projectSlug }).strict();
/** Replace one project's entry. The first write saves the defaults with it. */
export async function setProjectAuthority(input, by, root = bridgeRoot()) {
const { project, ...entry } = setAuthoritySchema.parse(input);
return mutate(authorityFile(root), async () => {
const { projects } = await readAuthority(root);
const next = { ...projects, [project]: entry };
await writeAuthority(root, next, by);
return projectAuthority({ projects: next }, project);
});
}
/** Drop one project's entry, which makes it go for everything. */
export async function clearProjectAuthority(input, by, root = bridgeRoot()) {
const { project } = z.object({ project: projectSlug }).strict().parse(input);
return mutate(authorityFile(root), async () => {
const { projects } = await readAuthority(root);
if (!Object.hasOwn(projects, project))
throw new BridgeError(404, `The release authority policy does not list ${project}.`);
const { [project]: _removed, ...rest } = projects;
await writeAuthority(root, rest, by);
return projectAuthority({ projects: rest }, project);
});
}
const confirmationSchema = z.object({
id: z.string().uuid(), project: projectSlug, actions: z.array(releaseAction).min(1).max(RELEASE_ACTIONS.length),
confirmedAt: z.string().datetime(), expiresAt: z.string().datetime(), by: writer,
}).strict();
const confirmationsListSchema = z.array(confirmationSchema).max(32);
export const confirmInputSchema = z.object({
project: projectSlug,
actions: z.array(releaseAction).min(1).max(RELEASE_ACTIONS.length),
minutes: z.number().int().min(1).max(24 * 60).optional().describe("How long the confirmation waits to be used; 30 when omitted."),
}).strict();
export const DEFAULT_CONFIRMATION_MINUTES = 30;
async function readConfirmations(root, now) {
const file = confirmationsFile(root);
if (!await privateFile(file, "authority-confirmations.json"))
return [];
const parsed = confirmationsListSchema.safeParse(JSON.parse(await readFile(file, "utf8")));
// A damaged list confirms nothing: the owner confirms again.
if (!parsed.success)
return [];
return parsed.data.filter(item => Date.parse(item.expiresAt) > now);
}
/** Confirmations still waiting to be used. */
export async function listConfirmations(root = bridgeRoot()) {
return readConfirmations(root, Date.now());
}
/** The owner's word for one agent dispatch of these release actions to this project. */
export async function confirmAuthority(input, by, root = bridgeRoot()) {
const data = confirmInputSchema.parse(input);
return mutate(confirmationsFile(root), async () => {
const now = Date.now();
const existing = await readConfirmations(root, now);
if (existing.length >= 32)
throw new BridgeError(409, "32 release confirmations are already waiting. Let some expire first.");
const confirmation = { id: randomUUID(), project: data.project, actions: [...new Set(data.actions)],
confirmedAt: new Date(now).toISOString(), expiresAt: new Date(now + (data.minutes ?? DEFAULT_CONFIRMATION_MINUTES) * 60_000).toISOString(), by };
await atomic(confirmationsFile(root), [...existing, confirmation]);
return confirmation;
});
}
/** Use up the oldest confirmation that covers every one of `actions` for
* `project`, or return undefined when none does. */
export async function consumeConfirmation(project, actions, root = bridgeRoot()) {
return mutate(confirmationsFile(root), async () => {
const now = Date.now();
const existing = await readConfirmations(root, now);
const index = existing.findIndex(item => item.project === project && actions.every(action => item.actions.includes(action)));
if (index < 0)
return undefined;
const [used] = existing.splice(index, 1);
await atomic(confirmationsFile(root), existing);
return used;
});
}
/**
* What the policy asks of an agent's dispatch: refuse a mode above the
* project's ceiling (lowered further by `grantCeiling`), start one that named
* no mode at that ceiling, and refuse an ask-first release action the owner
* has not confirmed. Only an agent's call is checked; the owner's never is.
* The confirmation is used up only when everything else passed.
*/
export async function checkAgentDispatch(data, grantCeiling, root = bridgeRoot()) {
const authority = projectAuthority(await readAuthority(root), data.project);
const ceiling = authority.maxPermissionMode ? lowerMode(authority.maxPermissionMode, grantCeiling) : undefined;
let permissionMode;
if (ceiling) {
if (data.harness === "opencode") {
throw new BridgeError(403, `${data.project} caps dispatched workers at ${ceiling}, and OpenCode takes its permissions from its own config. Dispatch Claude or Codex, or ask the owner to dispatch it.`);
}
if (data.permissionMode && PERMISSION_MODES.indexOf(data.permissionMode) > PERMISSION_MODES.indexOf(ceiling)) {
throw new BridgeError(403, `The release authority policy caps agent-dispatched workers for ${data.project} at ${ceiling}. Ask for ${ceiling} or lower, or ask the owner to dispatch it.`);
}
if (!data.permissionMode)
permissionMode = ceiling;
}
const needed = [...new Set(data.releaseActions ?? [])].filter(action => authority.ask.includes(action));
if (!needed.length)
return { ...(permissionMode ? { permissionMode } : {}), authority };
const confirmation = await consumeConfirmation(data.project, needed, root);
if (!confirmation) {
throw new BridgeError(403, `${data.project} is ask-first for ${needed.join(", ")}. Ask the owner; they confirm it on the phone or with \`phren authority confirm ${data.project} ${needed.join(",")}\`, or dispatch it themselves.`);
}
return { ...(permissionMode ? { permissionMode } : {}), authority, confirmation };
}