This commit is contained in:
NotExpectedYet committed 2026-09-26 06:45:50 +01:00
commit 0858862cda
17 files changed
+2189

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
/**
* 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() !== ''
}