fix: a delegated session inherits the facet it was spawned under
Release / release (push) Successful in 6s

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.
This commit is contained in:
NotExpectedYet committed 2026-09-27 07:43:03 +01:00
1 parent a9a25490db
commit 8ab3299694
4 files changed
+245 -11

No files matched your search

+94 -7
View File
@@ -97,6 +97,29 @@ export function createFacetPolicy(config) {
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.
@@ -112,6 +135,23 @@ export function createFacetPolicy(config) {
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.
*
@@ -123,16 +163,22 @@ export function createFacetPolicy(config) {
* 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) {
function rootFor(sessionId, sessionCwd, inherited) {
const declared = declaredScope(sessionId)?.focus
const base = typeof declared === 'string' && declared.trim() !== '' ? resolve(sessionCwd, declared) : sessionCwd
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) return base
if (agentFocus === base || agentFocus.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)) return agentFocus
if (agentFocus !== undefined && within(agentFocus, base)) return agentFocus
return base
}
@@ -162,7 +208,13 @@ export function createFacetPolicy(config) {
* 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.
* 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) {
@@ -170,9 +222,13 @@ export function createFacetPolicy(config) {
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: rootFor(sessionId, sessionCwd),
root,
extras: extrasFor(agent, sessionCwd),
}
}
@@ -284,10 +340,22 @@ export function createFacetPolicy(config) {
return [...(grants.get(sessionId) ?? [])]
}
/** Drop a finished session's grants. Called on `session/disposed`. */
/**
* 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 {
@@ -325,6 +393,25 @@ 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.
*