Files
dsh-facet/lib/policy.js
T
NotExpectedYet 8ab3299694
Release / release (push) Successful in 6s
fix: a delegated session inherits the facet it was spawned under
The user's declaration is keyed by session id, and a session spawned or forked
from a scoped one has none of its own — so its facet was its own working
directory, one level above its parent's. Delegation was therefore a way around
the scope rather than work inside it: the parent could not read the file itself
and could have a subagent read it.

A session now inherits the root resolved for the session named by its
`parentSession` header, which the harness already records durably for both a
spawned child and a fork. The inherited root is the parent's *effective* root, so
it already includes any narrowing the parent made for itself: delegating is not
a way back out of a self-imposed focus either. Because each session records its
own root as it resolves a scope, and a scope is resolved for every model request
and every tool call, inheritance holds at any depth of delegation rather than for
one hop.

Two boundaries are deliberate. A declaration the user made *for the child* is a
more specific statement about that session and stands — inheritance only answers
the case where they made none. And a parent's recorded root outlives its session
while any child that inherited it is still running, because disposing a parent
must not quietly widen a subagent that is still working.
2026-09-27 07:43:03 +01:00

424 lines
18 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, sep } 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`, `extraRoots` and `scopes` are
* edited live in the Web UI, and a policy that snapshotted them here would keep
* answering with the values it was loaded with.
*
* ## Two layers of scope, and only one of them the agent can move
*
* The session's scope is declared in two places on purpose, because the two
* audiences have opposite authority:
*
* * the **user layer** is `config.scopes[sessionId]` — a `focus` and a list of
* `extras`, written only from the browser context panel through the settings
* service. The agent has no write path to it: nothing in this plugin exposes
* one, and a facet session carries no settings tool.
* * the **agent layer** is in memory — a narrowing `focus` override and the
* grants it asked for. Both can only ever *reduce* what the user layer
* allows, so an agent that goes wrong cannot widen its own scope.
*
* The effective root is the agent's narrowing while it still sits inside the
* user's root, and the user's root otherwise. That ordering matters when the
* user narrows after the agent has: the stale agent focus is dropped rather than
* honoured, because honouring it would put the session outside the scope the
* user had just declared.
*
* @param {object} config
* @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled.
* @param {readonly string[]} [config.extraRoots] - a deployment-wide seed of standing grants.
* @param {Record<string, {focus?: string, extras?: readonly string[]}>} [config.scopes] - per-session scope, keyed by session id.
* @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()
/** @type {Map<string, string>} session id → the agent's narrowing focus, absolute. */
const narrowed = new Map()
/**
* The root resolved for each session, recorded as it is computed.
*
* A session spawned or forked from this one has no declaration of its own, so
* without this its facet would be its own working directory — wider than the
* conversation it came from, which is the one direction this whole plugin
* promises cannot happen. The recorded root already includes the parent's own
* narrowing, so a child is never wider than the parent is *now*, and the
* record's existence is what makes the inheritance work per level rather than
* for one hop.
*
* @type {Map<string, string>} session id → the root in force for it.
*/
const roots = new Map()
/**
* Child session id → the session it inherited from, for as long as both live.
*
* Kept so {@link forget} can tell a root nobody needs any more from one a
* delegated child is still answering to.
*
* @type {Map<string, string>}
*/
const spawnedFrom = new Map()
/**
* What the user declared for one session, read fresh.
*
* @param {string|undefined} sessionId - the session to read.
* @returns {{focus?: string, extras?: readonly string[]}|undefined} the declaration.
*/
function declaredScope(sessionId) {
if (sessionId === undefined) return undefined
const all = configValue(config.scopes)
if (all === null || typeof all !== 'object') return undefined
const entry = all[sessionId]
return entry !== null && typeof entry === 'object' ? entry : undefined
}
/**
* The session this one was spawned or forked from, when the header says so.
*
* The harness's own durable lineage field, so the link survives a restart and
* a child whose parent is no longer running is still contained. `fork` records
* the session it was seeded from and a subagent its direct parent; both are
* one conversation's work continuing, which is what makes inheriting the right
* answer for either.
*
* @param {{session?: {header?: {parentSession?: unknown}}}|undefined} agent - the calling agent.
* @returns {string|undefined} the parent's session id.
*/
function parentOf(agent) {
const parent = agent?.session?.header?.parentSession
return typeof parent === 'string' && parent !== '' ? parent : undefined
}
/**
* The root in force for one session.
*
* The user's `focus` is resolved against the session directory, not against
* the process, so a relative declaration keeps its meaning if the workspace
* moves. The agent's narrowing is applied only while it is still inside that
* root; a plain prefix test is enough here because the guard's own containment
* check, which resolves symlinks, is what authorises a narrowing in the first
* place — this one only has to notice a root that has since moved out from
* under it.
*
* Inheritance is the same kind of test, and it is skipped when the user made a
* declaration of their own: that is their decision about *this* session, and
* the point of inheriting is to answer the case where they made none.
*
* @param {string|undefined} sessionId - the session.
* @param {string} sessionCwd - its resolved working directory.
* @param {string} [inherited] - the root of the session it came from, if known.
* @returns {string} the absolute facet root.
*/
function rootFor(sessionId, sessionCwd, inherited) {
const declared = declaredScope(sessionId)?.focus
const stated = typeof declared === 'string' && declared.trim() !== ''
const own = stated ? resolve(sessionCwd, declared) : sessionCwd
const base = !stated && inherited !== undefined && within(inherited, own) ? inherited : own
const agentFocus = sessionId === undefined ? undefined : narrowed.get(sessionId)
if (agentFocus !== undefined && within(agentFocus, base)) return agentFocus
return base
}
/**
* 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".
*
* `cwd` stays the session directory even when the root is narrower, because
* it is what relative paths — a grant, a `focus`, an `extraRoots` entry —
* resolve against. A declaration written relative to the session keeps the
* same meaning whatever the session is focused on.
*
* Resolving a scope is also where a session's own root is *recorded*, which is
* what a session spawned from it later inherits. Every session resolves a
* scope before it can call anything — the binding is assembled for each model
* request, and the guard classifies each tool call — so a parent's root is
* always on the record by the time it can spawn or fork.
*
* @param {{session?: {header?: {cwd?: unknown, parentSession?: 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 sessionCwd = resolve(cwd)
const sessionId = idOf(agent)
const parent = parentOf(agent)
if (sessionId !== undefined && parent !== undefined) spawnedFrom.set(sessionId, parent)
const root = rootFor(sessionId, sessionCwd, parent === undefined ? undefined : roots.get(parent))
if (sessionId !== undefined) roots.set(sessionId, root)
return {
cwd: sessionCwd,
root,
extras: extrasFor(agent, sessionCwd),
}
}
/**
* Every path in scope beyond the root: the deployment seed, then what the user
* declared for this session, then what the agent was granted — deduplicated so
* the prompt does not repeat itself.
*
* All three are kept rather than layered, because they answer different
* questions. The seed is "this deployment always needs it", the declaration is
* "this conversation needs it", and a grant is "the user allowed this call".
* Removing one from the list when a narrower one exists would mean a user
* editing their panel could silently drop a path the deployment depends on.
*
* @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 = idOf(agent)
const own = sessionId === undefined ? [] : grants.get(sessionId) ?? []
const declared = declaredScope(sessionId)?.extras
const listed = Array.isArray(declared) ? declared.filter(isPath) : []
const all = [...standingRoots(), ...listed, ...own].map((path) =>
isAbsolute(path) ? resolve(path) : resolve(cwd, path),
)
return [...new Set(all)]
}
/**
* Narrow one session's facet to a path the caller has already authorised.
*
* The containment check is the caller's, and deliberately so: only the guard
* holds `ctx.fs`, and deciding containment on canonical targets is the whole
* reason a symlink inside the facet does not open it. This records the answer;
* {@link rootFor} re-checks that the user has not since narrowed past it.
*
* @param {string} sessionId - the session being narrowed.
* @param {string} absolute - the absolute root to narrow to.
* @returns {string} the recorded focus.
*/
function narrowTo(sessionId, absolute) {
narrowed.set(sessionId, absolute)
return absolute
}
/**
* @param {string} sessionId - the session to read.
* @returns {string|undefined} the agent's narrowing for it, if any.
*/
function narrowFor(sessionId) {
return narrowed.get(sessionId)
}
/**
* @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
}
/**
* Clear one session's agent-side scope: its grants and any narrowing it asked
* for.
*
* Deliberately not a widening. The user's declaration is not touched, so this
* returns the session to what the user set up in the panel — which may be
* narrower than where the agent had got to, and can never be wider.
*
* @param {string} sessionId - the session to clear.
* @returns {string[]} always empty.
*/
function reset(sessionId) {
grants.delete(sessionId)
narrowed.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 state. Called on `session/disposed`.
*
* The root is the one entry that is not simply dropped: a session spawned from
* this one may still be running, and it answers to the facet it was spawned
* under. So the record is kept while any live session still records this one
* as its parent, and released the moment none does. Without that, disposing a
* parent would quietly widen its children back to their own directory.
*
* @param {string} sessionId - the session being disposed.
*/
function forget(sessionId) {
grants.delete(sessionId)
narrowed.delete(sessionId)
spawnedFrom.delete(sessionId)
if (![...spawnedFrom.values()].includes(sessionId)) roots.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 deployment-wide seed of standing grants. A getter for the same reason as {@link mode}. */
get standing() {
return standingRoots()
},
/** What the user declared for one session: their `focus` and `extras`. */
declaredFor: declaredScope,
scopeFor,
rootFor,
narrowTo,
narrowFor,
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() !== ''
}
/**
* Whether one absolute path is the other, or sits inside it.
*
* A string test, deliberately, and the only one in this module. The guard's
* containment check — the one that decides whether a *call* may proceed — runs
* on canonical filesystem targets, because a symlink inside the facet must not
* open it. This one only compares two roots the plugin itself derived from the
* session directory, where there is nothing to resolve and a symlink would have
* had to be introduced by the declaration.
*
* @param {string} inner - the path that may be inside.
* @param {string} outer - the path that may contain it.
* @returns {boolean} whether it is inside.
*/
function within(inner, outer) {
if (inner === outer) return true
return inner.startsWith(outer.endsWith(sep) ? outer : `${outer}${sep}`)
}
/**
* The session id of a calling agent, when it has one.
*
* @param {{session?: {id?: unknown}}|undefined} agent - the calling agent.
* @returns {string|undefined} the id.
*/
function idOf(agent) {
return typeof agent?.session?.id === 'string' ? agent.session.id : undefined
}