init
This commit is contained in:
commit
0858862cda
17 files changed
+2189
No files matched your search
+148
@@ -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() !== ''
|
||||
}
|
||||
Reference in new issue
Block a user