UNPKG

@phren/cli

Version:

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

221 lines (220 loc) • 9.08 kB
/** * RBAC enforcement for mutating MCP tools. * * Access-control.json lives at `<phrenPath>/.config/access-control.json` (global). * Per-project overrides live in `phren.project.yaml` under the `access` key. * * Schema: * { admins: string[], contributors: string[], readers: string[] } * * Role hierarchy: * admins → all actions * contributors → add/edit/remove findings, complete/add/remove tasks * readers → read-only (search, get) * * When no access-control.json exists, all actors are permitted (open mode). * * The actor is read from the PHREN_ACTOR env var (falls back to open if unset). */ import * as fs from "fs"; import * as path from "path"; import { debugLog } from "../shared.js"; import { errorMessage } from "../utils.js"; import { readProjectConfig, writeProjectConfig } from "../project-config.js"; function configDir(phrenPath) { return path.join(phrenPath, ".config"); } function readGlobalAccessControl(phrenPath) { const filePath = path.join(configDir(phrenPath), "access-control.json"); if (!fs.existsSync(filePath)) return null; try { const raw = fs.readFileSync(filePath, "utf8"); const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; return parsed; } catch (err) { debugLog(`readGlobalAccessControl: failed to parse ${filePath}: ${errorMessage(err)}`); return null; } } function normalizeStringArray(value) { if (!Array.isArray(value)) return []; return value.filter((item) => typeof item === "string" && item.trim().length > 0); } /** * Appended to every ACL-write denial. The natural way to lock yourself out is * to run the very first `phren config access set --admins=someone-else`: that * turns open mode off and, because you are not in any list, every later edit * is denied. Recovery is a text edit, so say so rather than leaving the user * to guess. */ export const ACL_LOCKOUT_HINT = "Editing access control requires the `admins` role. To recover, edit `.config/access-control.json` in the store directly."; export const ACCESS_ROLE_KEYS = ["admins", "contributors", "readers"]; /** * Single source of truth for writing access-control role lists. Writes to the * global `.config/access-control.json` or, when `project` is given, to that * project's `phren.project.yaml` under the `access` key (merged with any * existing roles). Returns the role lists as written at the chosen scope. * * Shared by the CLI (`phren config access`) and the MCP `set_config` tool so * the two paths can never diverge. */ export function setAccessRoles(phrenPath, patch, project) { // The ACL is the one object whose own write has to be gated by itself. // Without this, `manage_config` was a role that existed in the table and was // never checked anywhere, so any actor the ACL denied `add_finding` could // call `set_config { action: "access", admins: ["me"] }` and promote itself // to admin — the ACL was a suggestion, not a boundary. // // This is the choke point for all three write surfaces (MCP set_config, the // web UI's POST /api/settings/access, and `phren config access set`); each // also checks up front so it can return its own error shape. Belt and // braces on purpose: the call-site checks give good errors, this one is what // a future fourth caller cannot forget. // // Bootstrapping still works: with no ACL configured anywhere, checkPermission // returns open mode, so the first `setAccessRoles` that creates the ACL is // always allowed. const denied = permissionDeniedError(phrenPath, "manage_config", project); if (denied) throw new Error(`${denied} ${ACL_LOCKOUT_HINT}`); const clean = {}; for (const key of ACCESS_ROLE_KEYS) { if (patch[key] !== undefined) clean[key] = normalizeStringArray(patch[key]); } if (project) { const current = readProjectConfig(phrenPath, project).access ?? {}; writeProjectConfig(phrenPath, project, { access: { ...current, ...clean } }); } else { const current = readGlobalAccessControl(phrenPath) ?? {}; const next = { ...current, ...clean }; const dir = configDir(phrenPath); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, "access-control.json"), JSON.stringify(next, null, 2) + "\n"); } const written = project ? (readProjectConfig(phrenPath, project).access ?? {}) : (readGlobalAccessControl(phrenPath) ?? {}); return { admins: normalizeStringArray(written.admins), contributors: normalizeStringArray(written.contributors), readers: normalizeStringArray(written.readers), }; } function mergeAccessControl(global, projectAccess) { // If neither global nor project access is configured, open mode if (!global && !projectAccess) return null; const base = global ?? {}; if (!projectAccess) return base; // Project-level access adds/replaces role lists; global is the baseline return { admins: [...new Set([...normalizeStringArray(base.admins), ...normalizeStringArray(projectAccess.admins)])], contributors: [...new Set([...normalizeStringArray(base.contributors), ...normalizeStringArray(projectAccess.contributors)])], readers: [...new Set([...normalizeStringArray(base.readers), ...normalizeStringArray(projectAccess.readers)])], }; } function resolvedActorRole(actor, ac) { const admins = normalizeStringArray(ac.admins); const contributors = normalizeStringArray(ac.contributors); const readers = normalizeStringArray(ac.readers); // If all role lists are empty, treat as open if (!admins.length && !contributors.length && !readers.length) return "admin"; if (admins.includes(actor)) return "admin"; if (contributors.includes(actor)) return "contributor"; if (readers.includes(actor)) return "reader"; return "denied"; } const CONTRIBUTOR_ACTIONS = new Set([ "add_finding", "remove_finding", "edit_finding", "complete_task", "add_task", "remove_task", "update_task", "pin_task", "claim_task", "add_note", "edit_note", "remove_note", "promote_note", ]); const ADMIN_ONLY_ACTIONS = new Set([ "manage_config", ]); function rolePermits(role, action) { if (role === "denied") return false; if (role === "admin") return true; if (ADMIN_ONLY_ACTIONS.has(action)) return false; if (role === "contributor") return CONTRIBUTOR_ACTIONS.has(action); // readers: no mutating actions return false; } /** * Check whether the current actor (from PHREN_ACTOR env var) is permitted to * perform `action`. When `project` is provided, merges global + per-project ACL. * * Returns `{ allowed: true }` when permitted, `{ allowed: false, reason }` when denied. */ function checkPermission(phrenPath, action, project) { const actor = (process.env.PHREN_ACTOR ?? "").trim() || null; const globalAc = readGlobalAccessControl(phrenPath); const projectAccess = project ? readProjectConfig(phrenPath, project).access : undefined; const effectiveAc = mergeAccessControl(globalAc, projectAccess); // Open mode: no access control configured at any level if (!effectiveAc) { return { allowed: true, actor, role: "open" }; } // No actor env var set — check if we're in open mode still if (!actor) { // If all lists are empty, open const allEmpty = !normalizeStringArray(effectiveAc.admins).length && !normalizeStringArray(effectiveAc.contributors).length && !normalizeStringArray(effectiveAc.readers).length; if (allEmpty) return { allowed: true, actor, role: "open" }; return { allowed: false, actor, role: "denied", reason: "PHREN_ACTOR is not set and access control is configured. Set PHREN_ACTOR to your username.", }; } const role = resolvedActorRole(actor, effectiveAc); const allowed = rolePermits(role, action); if (!allowed) { const reason = role === "denied" ? `Actor "${actor}" is not listed in access-control for ${project ? `project "${project}"` : "global config"}.` : `Actor "${actor}" has role "${role}" which does not permit "${action}".`; return { allowed: false, actor, role, reason }; } return { allowed: true, actor, role }; } /** * Convenience wrapper: returns a permission-denied MCP error string, * or null if the action is allowed. */ export function permissionDeniedError(phrenPath, action, project) { const result = checkPermission(phrenPath, action, project); if (result.allowed) return null; return `Permission denied: ${result.reason ?? `actor "${result.actor}" cannot perform "${action}".`}`; }