Files
dsh-facet/lib/policy.js
T
NotExpectedYet 28f20a8aac fix: show the settings card, and make an edit actually take effect
`mode` and `extraRoots` carried no `volatile()` marker, so the framework's
form projection dropped the whole entry from `settings.describe` and the plugin
had no card under Plugins at all. The marker is what creates the card.

It is also what turns each field into a reference rather than a value: the
resolved config hands `apply` a `{get(), [write]}` per volatile field, which is
how a settings write lands in a running plugin without re-applying it. So the
policy and the guard now read both fields at the point of use instead of
snapshotting them at mount, and `sectionOrder`/`contextOrder` stay unmarked
because a prompt section cannot be re-ordered without a remount.

A section is also registered once as a plain string, so the rule could not
follow a live `mode` without freezing at load. The mode-specific sentence moved
into the binding, which is a callback.
2026-09-26 07:13:33 +01:00

207 lines
7.8 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'
/**
* 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` and `extraRoots` are edited live
* in the Web UI, and a policy that snapshotted them here would keep answering
* with the values it was loaded with.
*
* @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()
/**
* 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".
*
* @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 = [...standingRoots(), ...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 {
/** 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 standing grants. A getter for the same reason as {@link mode}. */
get standing() {
return standingRoots()
},
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() !== ''
}