@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
232 lines (231 loc) • 12.3 kB
JavaScript
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { nonInteractiveGitEnv } from "../utils-helpers.js";
import { createHash, createPublicKey, verify } from "node:crypto";
import { lstat, mkdir, readFile, realpath } from "node:fs/promises";
import { hostname } from "node:os";
import path from "node:path";
import { z } from "zod";
import { homeDir } from "../home-paths.js";
import { tryFileLock } from "../governance/locks.js";
import { atomic, BridgeError, bridgeRoot, object } from "./protocol.js";
const exec = promisify(execFile);
// Each context needs at most two reads; slow Git falls back to ordinary approval.
async function git(cwd, ...args) {
return (await exec("git", ["-C", cwd, "--no-pager", ...args], {
timeout: 150, maxBuffer: 4_194_304,
env: nonInteractiveGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_CONFIG_NOSYSTEM: "1" }),
})).stdout;
}
const text = z.string().min(1).max(4096).refine(value => !/[\x00-\x1f\x7f]/.test(value));
const scopeSchema = z.object({
project: text.refine(value => path.isAbsolute(value) && path.normalize(value) === value),
harness: z.enum(["claude", "codex", "copilot", "opencode", "phren"]).optional(),
session: text.optional(), computer: text.optional(),
}).strict();
export const approvalRuleDraftSchema = z.object({
tool: z.literal("Bash"), command: text, match: z.enum(["exact", "prefix", "glob"]),
effect: z.enum(["always-ask", "deny"]), scope: scopeSchema,
projectName: z.string().min(1).max(100), until: z.string().datetime({ offset: true }).optional(),
}).strict();
const operationSchema = z.discriminatedUnion("operation", [
z.object({ domain: z.literal("phren-approval-rules-v1"), operation: z.literal("add"), nonce: z.string().uuid(), at: z.string().datetime({ offset: true }), rule: approvalRuleDraftSchema }).strict(),
z.object({ domain: z.literal("phren-approval-rules-v1"), operation: z.literal("set-enabled"), nonce: z.string().uuid(), at: z.string().datetime({ offset: true }), id: z.string().uuid(), enabled: z.boolean() }).strict(),
z.object({ domain: z.literal("phren-approval-rules-v1"), operation: z.literal("revoke"), nonce: z.string().uuid(), at: z.string().datetime({ offset: true }), id: z.string().uuid() }).strict(),
]);
const envelopeSchema = z.object({ payload: z.string().min(1).max(16384), signature: z.string().min(1).max(128), publicKey: z.string().min(1).max(64) }).strict();
const policySchema = z.object({ operations: z.array(envelopeSchema).max(2048) }).strict();
const policyPath = (root) => path.join(root, "approval-rules.json");
const forbidden = () => new BridgeError(403, "Confirm this change with the paired iPhone's signing key.");
/** Restricted phone keys only; dispatch keys and arbitrary SSH keys cannot sign policy. */
async function ownerKeys() {
const keys = await readFile(path.join(homeDir(), ".ssh", "authorized_keys"), "utf8");
const result = new Set();
for (const line of keys.split("\n")) {
const match = /^restrict,pty,command="sh ~\/\.local\/share\/phren\/bridge\/dispatch" ssh-ed25519 ([A-Za-z0-9+/]+={0,2}) phren-iphone\s*$/.exec(line);
if (!match)
continue;
const wire = Buffer.from(match[1], "base64");
// SSH string("ssh-ed25519"), string(32-byte key).
if (wire.length === 51 && wire.readUInt32BE(0) === 11 && wire.subarray(4, 15).toString() === "ssh-ed25519" && wire.readUInt32BE(15) === 32)
result.add(wire.subarray(19).toString("base64"));
}
return result;
}
function verified(envelope, keys) {
const raw = Buffer.from(envelope.publicKey, "base64"), signature = Buffer.from(envelope.signature, "base64"), payload = Buffer.from(envelope.payload, "base64");
if (raw.length !== 32 || signature.length !== 64 || !keys.has(raw.toString("base64")))
throw forbidden();
const key = createPublicKey({ key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), raw]), format: "der", type: "spki" });
if (!verify(null, payload, key, signature))
throw forbidden();
const operation = operationSchema.parse(JSON.parse(payload.toString("utf8")));
return { operation, owner: createHash("sha256").update(raw).digest("hex") };
}
async function privateJSON(file, fallback, max = 2_097_152) {
const info = await lstat(file).catch(error => { if (error.code === "ENOENT")
return undefined; throw error; });
if (!info)
return fallback;
if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077) || info.size > max)
throw new BridgeError(409, "Approval policy must be a private regular file.");
return JSON.parse(await readFile(file, "utf8"));
}
async function readPolicy(root) {
const policy = policySchema.parse(await privateJSON(policyPath(root), { operations: [] }));
const keys = policy.operations.length ? await ownerKeys() : new Set();
const rules = new Map(), nonces = new Set();
for (const envelope of policy.operations) {
const { operation, owner } = verified(envelope, keys);
if (nonces.has(operation.nonce))
throw new BridgeError(409, "Repeated policy operation.");
nonces.add(operation.nonce);
if (operation.operation === "add")
rules.set(operation.nonce, { ...operation.rule, id: operation.nonce, createdAt: operation.at, owner, enabled: true });
else if (operation.operation === "revoke")
rules.delete(operation.id);
else {
const rule = rules.get(operation.id);
if (rule)
rule.enabled = operation.enabled;
}
}
return { policy, rules, nonces };
}
const queues = new Map();
async function locked(root, work) {
const key = path.resolve(root), previous = queues.get(key) ?? Promise.resolve();
const next = previous.catch(() => { }).then(async () => {
await mkdir(key, { recursive: true, mode: 0o700 });
const release = tryFileLock(policyPath(key));
if (!release)
throw new BridgeError(409, "Approval rules are busy. Try again.");
try {
return await work();
}
finally {
release();
}
});
queues.set(key, next);
try {
return await next;
}
finally {
if (queues.get(key) === next)
queues.delete(key);
}
}
export async function listApprovalRules(root = bridgeRoot(), pairedKey) {
try {
const pairedOwner = pairedKey ? createHash("sha256").update(Buffer.from(pairedKey, "base64")).digest("hex") : undefined;
return [...(await readPolicy(root)).rules.values()].map(rule => ({ ...rule,
signerFingerprint: "SHA256:" + Buffer.from(rule.owner, "hex").toString("base64").replace(/=+$/, ""),
matchesPairedKey: rule.owner === pairedOwner,
}));
}
catch {
throw new BridgeError(409, "Approval rules could not be loaded. Existing agent permissions and phone approvals continue unchanged.");
}
}
/** The signed operations remain on disk and are verified again at every decision. */
export async function changeApprovalRule(input, method, root = bridgeRoot()) {
const envelope = envelopeSchema.parse(input), proof = verified(envelope, await ownerKeys());
if ((method === "DELETE" ? proof.operation.operation !== "revoke" : proof.operation.operation === "revoke") || !Number.isFinite(Date.parse(proof.operation.at)) || Math.abs(Date.now() - Date.parse(proof.operation.at)) > 300_000)
throw forbidden();
return locked(root, async () => {
const { policy, rules, nonces } = await readPolicy(root);
if (nonces.has(proof.operation.nonce))
throw new BridgeError(409, "This change has already been applied.");
if (proof.operation.operation === "add" && rules.size >= 128)
throw new BridgeError(409, "Revoke a rule before adding another.");
if (proof.operation.operation !== "add" && !rules.has(proof.operation.id))
throw new BridgeError(409, "This rule is no longer listed.");
if (policy.operations.length >= 2048)
throw new BridgeError(409, "Approval policy history is full; owner maintenance is required.");
policy.operations.push(envelope);
const bytes = JSON.stringify(policy);
if (Buffer.byteLength(bytes) > 2_097_152)
throw new BridgeError(409, "Approval policy history is full; owner maintenance is required.");
await atomic(policyPath(root), bytes);
return { ok: true };
});
}
/** Bind project scope to the main repository, including linked worktrees. */
export async function approvalRuleContext(cwd, harness, session) {
if (!path.isAbsolute(cwd))
return undefined;
try {
const directories = (await git(cwd, "rev-parse", "--path-format=absolute", "--git-common-dir", "--show-toplevel")).trim().split(/\r?\n/);
if (directories.length !== 2)
return undefined;
const [common, checkout, directory] = await Promise.all([...directories, cwd].map(value => realpath(value)));
const relative = path.relative(checkout, directory);
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
return undefined;
if (path.basename(common) !== ".git")
return undefined;
// A writable .git pointer alone is not proof that a folder belongs to this repository.
const listed = (await git(common, "worktree", "list", "--porcelain", "-z")).split("\0")
.filter(field => field.startsWith("worktree ")).map(field => field.slice("worktree ".length));
const registered = await Promise.all(listed.map(directory => realpath(directory).catch(() => undefined)));
if (!registered.includes(checkout))
return undefined;
return { project: path.dirname(common), harness: scopeSchema.shape.harness.unwrap().parse(harness), session, computer: hostname() };
}
catch {
return undefined;
}
}
function commandMatches(rule, command) {
if (rule.match === "exact")
return command === rule.command;
if (rule.match === "prefix")
return command === rule.command || command.startsWith(rule.command + " ");
// Bound pattern/command sizes avoid unbounded glob backtracking.
let i = 0, j = 0, star = -1, retry = 0;
while (j < command.length) {
if (i < rule.command.length && (rule.command[i] === "?" || rule.command[i] === command[j])) {
i++;
j++;
}
else if (rule.command[i] === "*") {
star = i++;
retry = j;
}
else if (star !== -1) {
i = star + 1;
j = ++retry;
}
else
return false;
}
while (rule.command[i] === "*")
i++;
return i === rule.command.length;
}
/** Rules can only restrict a request the harness has already raised. Invalid policy
* falls back to that ordinary approval path; Settings reports the load error. */
export async function approvalRuleEffect(tool, input, context, root = bridgeRoot(), knownScope = {}) {
const command = object(input).command;
if (tool !== "Bash" || typeof command !== "string")
return undefined;
try {
const { rules } = await readPolicy(root), now = Date.now();
const applicable = [...rules.values()].filter(rule => rule.enabled && rule.tool === tool && (!rule.until || Date.parse(rule.until) > now)
&& Object.entries(knownScope).every(([key, value]) => !rule.scope[key] || rule.scope[key] === value)
&& commandMatches(rule, command));
if (!applicable.length)
return undefined;
const resolved = typeof context === "function" ? await context() : context;
if (!resolved)
return undefined;
const matches = applicable.filter(rule => Object.entries(rule.scope).every(([key, value]) => resolved[key] === value));
if (matches.some(rule => rule.effect === "deny"))
return "deny";
return matches.some(rule => rule.effect === "always-ask") ? "always-ask" : undefined;
}
catch {
return undefined;
}
}