/** * The deployment half of the facet: what is always in scope, and what one * session has been granted. * * Split from {@link ./scope.js} because these two halves fail differently. The * scope algebra is pure and total — it must hold for any input, including a * malformed tool call. This module holds mutable state keyed by session, and its * mistakes are leaks: a grant that outlives its session, or a path kept in a * form that no longer resolves. Keeping the state here and the rules there means * the rules can be tested exhaustively without standing up a session. * * A grant is deliberately ephemeral. It lives for the conversation that asked * for it and is not written anywhere, because "this session may also see * `src/shared`" is a statement about one conversation, and persisting it would * quietly widen every future one — which is the failure mode the whole plugin * exists to prevent. */ import { isAbsolute, resolve, sep } from 'node:path' /** * The symbol cosmokit uses to mark a volatile config reference. * * Looked up by symbol rather than by importing cosmokit, because the ESM and CJS * copies of that library are distinct module instances and `Symbol.for` is the * only identity they share. */ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write') /** * Read one configuration field. * * A `volatile()` field is a *reference* in the running config, not a plain * value: the framework resolves the schema through `Schema.resolve`, which wraps * each volatile field in `createVolatile(...)` — `{get(), [write]}`. A * non-volatile field, or a config object a test handed straight to * {@link createFacetPolicy}, is the value itself. Both are accepted, so one code * path serves a real profile and a direct call. * * Every read has to happen at the point of use rather than once at construction. * A settings write commits into these references in place and deliberately does * *not* re-apply the plugin, so anything captured at construction would never * see the change — which is how a plugin ends up with a settings card that does * nothing. * * @param {unknown} value - a field of the resolved config. * @returns {unknown} the current value. */ export function configValue(value) { return typeof value === 'object' && value !== null && VOLATILE_WRITE in value ? value.get() : value } /** * @typedef {object} FacetScope * @property {string} cwd - the session working directory, absolute. * @property {string} root - the facet root; the session's own folder. * @property {string[]} extras - absolute granted paths outside the root. */ /** * Build the policy for one deployment. * * `config` is the *resolved* composition config, so its volatile fields are * references rather than values; every read below goes through * {@link configValue} and happens per use. Holding the config and reading it lazily * is what makes the settings card honest: `mode`, `extraRoots` and `scopes` are * edited live in the Web UI, and a policy that snapshotted them here would keep * answering with the values it was loaded with. * * ## Two layers of scope, and only one of them the agent can move * * The session's scope is declared in two places on purpose, because the two * audiences have opposite authority: * * * the **user layer** is `config.scopes[sessionId]` — a `focus` and a list of * `extras`, written only from the browser context panel through the settings * service. The agent has no write path to it: nothing in this plugin exposes * one, and a facet session carries no settings tool. * * the **agent layer** is in memory — a narrowing `focus` override and the * grants it asked for. Both can only ever *reduce* what the user layer * allows, so an agent that goes wrong cannot widen its own scope. * * The effective root is the agent's narrowing while it still sits inside the * user's root, and the user's root otherwise. That ordering matters when the * user narrows after the agent has: the stale agent focus is dropped rather than * honoured, because honouring it would put the session outside the scope the * user had just declared. * * @param {object} config * @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled. * @param {readonly string[]} [config.extraRoots] - a deployment-wide seed of standing grants. * @param {Record} [config.scopes] - per-session scope, keyed by session id. * @returns {object} the `facetPolicy` service implementation. */ export function createFacetPolicy(config) { /** @type {Map} session id → granted paths, in the order granted. */ const grants = new Map() /** @type {Map} session id → the agent's narrowing focus, absolute. */ const narrowed = new Map() /** * What the user declared for one session, read fresh. * * @param {string|undefined} sessionId - the session to read. * @returns {{focus?: string, extras?: readonly string[]}|undefined} the declaration. */ function declaredScope(sessionId) { if (sessionId === undefined) return undefined const all = configValue(config.scopes) if (all === null || typeof all !== 'object') return undefined const entry = all[sessionId] return entry !== null && typeof entry === 'object' ? entry : undefined } /** * The root in force for one session. * * The user's `focus` is resolved against the session directory, not against * the process, so a relative declaration keeps its meaning if the workspace * moves. The agent's narrowing is applied only while it is still inside that * root; a plain prefix test is enough here because the guard's own containment * check, which resolves symlinks, is what authorises a narrowing in the first * place — this one only has to notice a root that has since moved out from * under it. * * @param {string|undefined} sessionId - the session. * @param {string} sessionCwd - its resolved working directory. * @returns {string} the absolute facet root. */ function rootFor(sessionId, sessionCwd) { const declared = declaredScope(sessionId)?.focus const base = typeof declared === 'string' && declared.trim() !== '' ? resolve(sessionCwd, declared) : sessionCwd const agentFocus = sessionId === undefined ? undefined : narrowed.get(sessionId) if (agentFocus === undefined) return base if (agentFocus === base || agentFocus.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)) return agentFocus return base } /** * The deployment's standing grants, read fresh on every call. * * Not filtered once at construction: `extraRoots` is volatile, so an edit in * the settings UI replaces the reference's contents without remounting this * plugin, and the next scope resolution is expected to see it. * * @returns {string[]} configured paths, blanks dropped. */ function standingRoots() { const configured = configValue(config.extraRoots) return Array.isArray(configured) ? configured.filter(isPath) : [] } /** * The scope of one agent, or `undefined` when it has none. * * An agent without a working directory is not scoped rather than scoped to * something arbitrary: `undefined` here means the guard has no opinion, which * is the only safe reading of "we do not know where this agent is". * * `cwd` stays the session directory even when the root is narrower, because * it is what relative paths — a grant, a `focus`, an `extraRoots` entry — * resolve against. A declaration written relative to the session keeps the * same meaning whatever the session is focused on. * * @param {{session?: {header?: {cwd?: unknown}, id?: unknown}}|undefined} agent - the calling agent. * @returns {FacetScope|undefined} the scope. */ function scopeFor(agent) { const cwd = agent?.session?.header?.cwd if (typeof cwd !== 'string' || cwd.trim() === '') return undefined const sessionCwd = resolve(cwd) const sessionId = idOf(agent) return { cwd: sessionCwd, root: rootFor(sessionId, sessionCwd), extras: extrasFor(agent, sessionCwd), } } /** * Every path in scope beyond the root: the deployment seed, then what the user * declared for this session, then what the agent was granted — deduplicated so * the prompt does not repeat itself. * * All three are kept rather than layered, because they answer different * questions. The seed is "this deployment always needs it", the declaration is * "this conversation needs it", and a grant is "the user allowed this call". * Removing one from the list when a narrower one exists would mean a user * editing their panel could silently drop a path the deployment depends on. * * @param {{session?: {id?: unknown}}|undefined} agent - the calling agent. * @param {string} cwd - the resolved session directory. * @returns {string[]} absolute paths. */ function extrasFor(agent, cwd) { const sessionId = idOf(agent) const own = sessionId === undefined ? [] : grants.get(sessionId) ?? [] const declared = declaredScope(sessionId)?.extras const listed = Array.isArray(declared) ? declared.filter(isPath) : [] const all = [...standingRoots(), ...listed, ...own].map((path) => isAbsolute(path) ? resolve(path) : resolve(cwd, path), ) return [...new Set(all)] } /** * Narrow one session's facet to a path the caller has already authorised. * * The containment check is the caller's, and deliberately so: only the guard * holds `ctx.fs`, and deciding containment on canonical targets is the whole * reason a symlink inside the facet does not open it. This records the answer; * {@link rootFor} re-checks that the user has not since narrowed past it. * * @param {string} sessionId - the session being narrowed. * @param {string} absolute - the absolute root to narrow to. * @returns {string} the recorded focus. */ function narrowTo(sessionId, absolute) { narrowed.set(sessionId, absolute) return absolute } /** * @param {string} sessionId - the session to read. * @returns {string|undefined} the agent's narrowing for it, if any. */ function narrowFor(sessionId) { return narrowed.get(sessionId) } /** * @param {string} sessionId - the session being widened. * @param {readonly string[]} paths - raw paths, resolved against `cwd`. * @param {string} cwd - the session directory. * @returns {string[]} the resulting grant list. */ function grant(sessionId, paths, cwd) { const resolved = paths .filter(isPath) .map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path))) const next = [...new Set([...(grants.get(sessionId) ?? []), ...resolved])] grants.set(sessionId, next) return next } /** * @param {string} sessionId - the session being narrowed. * @param {readonly string[]} paths - raw paths to drop. * @param {string} cwd - the session directory. * @returns {string[]} the resulting grant list. */ function revoke(sessionId, paths, cwd) { const drop = new Set( paths.filter(isPath).map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path))), ) const next = (grants.get(sessionId) ?? []).filter((path) => !drop.has(path)) if (next.length === 0) grants.delete(sessionId) else grants.set(sessionId, next) return next } /** * Clear one session's agent-side scope: its grants and any narrowing it asked * for. * * Deliberately not a widening. The user's declaration is not touched, so this * returns the session to what the user set up in the panel — which may be * narrower than where the agent had got to, and can never be wider. * * @param {string} sessionId - the session to clear. * @returns {string[]} always empty. */ function reset(sessionId) { grants.delete(sessionId) narrowed.delete(sessionId) return [] } /** * @param {string} sessionId - the session to read. * @returns {string[]} its own grants, without the standing ones. */ function grantsFor(sessionId) { return [...(grants.get(sessionId) ?? [])] } /** Drop a finished session's grants. Called on `session/disposed`. */ function forget(sessionId) { grants.delete(sessionId) narrowed.delete(sessionId) } return { /** The enforcement mode. A getter, so a settings edit is answered on the next call. */ get mode() { return configValue(config.mode) }, /** Prompt placements, carried here so the preset row needs no configuration of its own. */ sectionOrder: configValue(config.sectionOrder) ?? 100, contextOrder: configValue(config.contextOrder) ?? 105, /** The deployment-wide seed of standing grants. A getter for the same reason as {@link mode}. */ get standing() { return standingRoots() }, /** What the user declared for one session: their `focus` and `extras`. */ declaredFor: declaredScope, scopeFor, rootFor, narrowTo, narrowFor, grant, revoke, reset, grantsFor, forget, /** Session ids currently holding a grant — for tests and diagnostics. */ get sessions() { return [...grants.keys()] }, } } /** @param {unknown} value @returns {value is string} */ function isPath(value) { return typeof value === 'string' && value.trim() !== '' } /** * The session id of a calling agent, when it has one. * * @param {{session?: {id?: unknown}}|undefined} agent - the calling agent. * @returns {string|undefined} the id. */ function idOf(agent) { return typeof agent?.session?.id === 'string' ? agent.session.id : undefined }