feat: scope a session from a declaration the agent cannot widen
The facet root was the session's working directory and nothing else, so the only way to scope a conversation to a module was to make the module a workspace — and the only way to reach anything outside it was `extraRoots`, which is a fact about the deployment rather than about one conversation. Scope now has two layers, and the split is the point. 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, which is what `settings.mutate` requiring a volatile path buys us. The **agent layer** is in memory: a narrowing `focus` and its grants. Both can only ever reduce what the user layer allows, so an agent that goes wrong cannot widen its own scope. `facet action=focus` is therefore the one scope change the agent may make unasked, bounded by the scope already in force and decided on canonical targets. `reset` clears the agent's side only, landing on the user's declaration rather than on the session directory — landing on the directory would have made `reset` a widening. `extraRoots` demotes to an optional deployment-wide seed. The binding now names the context panel as the way to widen, because it is: in a deployment with no approval channel, `facet action=add` cannot be granted at all.
This commit is contained in:
1 parent
f36d46efa1
commit
d9caf4eeec
8 files changed
+431
-52
No files matched your search
+142
-12
@@ -16,7 +16,7 @@
|
||||
* exists to prevent.
|
||||
*/
|
||||
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
import { isAbsolute, resolve, sep } from 'node:path'
|
||||
|
||||
/**
|
||||
* The symbol cosmokit uses to mark a volatile config reference.
|
||||
@@ -63,18 +63,78 @@ export function configValue(value) {
|
||||
* `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.
|
||||
* 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] - standing grants, resolved against each session's cwd.
|
||||
* @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()
|
||||
|
||||
/**
|
||||
* 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 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.
|
||||
*
|
||||
* @param {string|undefined} sessionId - the session.
|
||||
* @param {string} sessionCwd - its resolved working directory.
|
||||
* @returns {string} the absolute facet root.
|
||||
*/
|
||||
function rootFor(sessionId, sessionCwd) {
|
||||
const declared = declaredScope(sessionId)?.focus
|
||||
const base = typeof declared === 'string' && declared.trim() !== '' ? resolve(sessionCwd, declared) : sessionCwd
|
||||
const agentFocus = sessionId === undefined ? undefined : narrowed.get(sessionId)
|
||||
if (agentFocus === undefined) return base
|
||||
if (agentFocus === base || agentFocus.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)) return agentFocus
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* The deployment's standing grants, read fresh on every call.
|
||||
@@ -97,31 +157,77 @@ export function createFacetPolicy(config) {
|
||||
* 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.
|
||||
*
|
||||
* @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) }
|
||||
const sessionCwd = resolve(cwd)
|
||||
const sessionId = idOf(agent)
|
||||
return {
|
||||
cwd: sessionCwd,
|
||||
root: rootFor(sessionId, sessionCwd),
|
||||
extras: extrasFor(agent, sessionCwd),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every path in scope beyond the root: standing grants first, then this
|
||||
* session's, deduplicated so the prompt does not repeat itself.
|
||||
* 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 = typeof agent?.session?.id === 'string' ? agent.session.id : undefined
|
||||
const sessionId = idOf(agent)
|
||||
const own = sessionId === undefined ? [] : grants.get(sessionId) ?? []
|
||||
const all = [...standingRoots(), ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path)))
|
||||
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`.
|
||||
@@ -154,11 +260,19 @@ export function createFacetPolicy(config) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 []
|
||||
}
|
||||
|
||||
@@ -173,6 +287,7 @@ export function createFacetPolicy(config) {
|
||||
/** Drop a finished session's grants. Called on `session/disposed`. */
|
||||
function forget(sessionId) {
|
||||
grants.delete(sessionId)
|
||||
narrowed.delete(sessionId)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -183,11 +298,16 @@ export function createFacetPolicy(config) {
|
||||
/** 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}. */
|
||||
/** 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,
|
||||
@@ -204,3 +324,13 @@ export function createFacetPolicy(config) {
|
||||
function isPath(value) {
|
||||
return typeof value === 'string' && value.trim() !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in new issue
Block a user