@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
221 lines (220 loc) • 9.08 kB
JavaScript
/**
* 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}".`}`;
}