/** * 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 } from 'node:path' /** * @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. * * @param {object} config * @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled. * @param {readonly string[]} [config.extraRoots] - standing grants, resolved against each session's cwd. * @returns {object} the `facetPolicy` service implementation. */ export function createFacetPolicy(config) { /** @type {Map} session id → granted paths, in the order granted. */ const grants = new Map() const standing = Array.isArray(config.extraRoots) ? config.extraRoots.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". * * @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 root = resolve(cwd) return { cwd: root, root, extras: extrasFor(agent, root) } } /** * Every path in scope beyond the root: standing grants first, then this * session's, deduplicated so the prompt does not repeat itself. * * @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 = typeof agent?.session?.id === 'string' ? agent.session.id : undefined const own = sessionId === undefined ? [] : grants.get(sessionId) ?? [] const all = [...standing, ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path))) return [...new Set(all)] } /** * @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 } /** * @param {string} sessionId - the session to clear. * @returns {string[]} always empty. */ function reset(sessionId) { grants.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) } return { mode: config.mode, /** Prompt placements, carried here so the preset row needs no configuration of its own. */ sectionOrder: config.sectionOrder ?? 100, contextOrder: config.contextOrder ?? 105, standing: [...standing], scopeFor, 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() !== '' }