149 lines
5.4 KiB
JavaScript
149 lines
5.4 KiB
JavaScript
/**
|
|
* 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<string, string[]>} 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() !== ''
|
|
}
|