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

+15 -4
View File
@@ -154,6 +154,15 @@ and your root otherwise. So an agent that goes wrong cannot widen its own scope,
and `facet action=reset` returns it to what you declared rather than to the and `facet action=reset` returns it to what you declared rather than to the
session directory — landing on the directory would have made `reset` a widening. session directory — landing on the directory would have made `reset` a widening.
**A session spawned or forked from a scoped one inherits its root.** Your
declaration is keyed by session id, and a subagent or a fork has none of its own,
so without this its facet would be its own working directory — one level up, and
a way to delegate around the scope rather than work inside it. The inherited root
already includes the parent's own narrowing, so delegating is not a way back out
of a focus the agent set for itself either, and it is recorded per level, so it
holds at any depth of delegation. It applies only where you made no statement:
a declaration for the child session is the more specific answer and stands.
You set the declaration from the **crosshair button on the composer tool row**, You set the declaration from the **crosshair button on the composer tool row**,
which appears only in sessions running the `facet` preset. It shows the focus and which appears only in sessions running the `facet` preset. It shows the focus and
the allowed paths, adds and removes them, and carries the mode details and usage the allowed paths, adds and removes them, and carries the mode details and usage
@@ -237,7 +246,7 @@ Honest boundaries, because each one is a decision rather than an oversight:
Verified: Verified:
- 81 tests (`npm test`) over the scope algebra, the policy state, the guard, and - 88 tests (`npm test`) over the scope algebra, the policy state, the guard, and
the browser half — including that containment is decided on canonical targets the browser half — including that containment is decided on canonical targets
(a symlink inside the facet pointing outside it is refused), that a prefix is (a symlink inside the facet pointing outside it is refused), that a prefix is
not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool not a parent (`/a/room` does not contain `/a/roommate`), that a malformed tool
@@ -245,8 +254,10 @@ Verified:
without the guard being remounted, that an agent narrowing the user has since without the guard being remounted, that an agent narrowing the user has since
narrowed past is dropped rather than honoured, that `reset` lands on the narrowed past is dropped rather than honoured, that `reset` lands on the
declared scope rather than the session directory, that a search naming no path declared scope rather than the session directory, that a search naming no path
is refused when the directory it defaults to is outside the facet, and that is refused when the directory it defaults to is outside the facet, that a
every `facet` result satisfies the tool's own declared output schema. delegated session inherits its parent's root at any depth and is not a way back
out of a self-narrowing, and that every `facet` result satisfies the tool's own
declared output schema.
- The bundle's patch layers parse, merge and land correctly against the real - The bundle's patch layers parse, merge and land correctly against the real
profile: `dsh --profile web --patch ./cordis.patch.yml --patch profile: `dsh --profile web --patch ./cordis.patch.yml --patch
./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`, ./presets/facet.patch.yml --dump-config` shows `facet-policy`, `preset-facet`,
@@ -324,7 +335,7 @@ lib/scope.js the pure half — argument extraction, text, reason
lib/guard.js preset row: rule, binding, tool, pre-execute guard lib/guard.js preset row: rule, binding, tool, pre-execute guard
presets/facet.patch.yml the `facet` agent preset presets/facet.patch.yml the `facet` agent preset
presets/standard-compaction.patch.yml optional: the same budget for `standard` presets/standard-compaction.patch.yml optional: the same budget for `standard`
test/ 81 tests test/ 88 tests
``` ```
`client/client.js` is a browser half rather than a module the host imports, and `client/client.js` is a browser half rather than a module the host imports, and
+94 -7
View File
@@ -97,6 +97,29 @@ export function createFacetPolicy(config) {
const grants = new Map() const grants = new Map()
/** @type {Map<string, string>} session id → the agent's narrowing focus, absolute. */ /** @type {Map<string, string>} session id → the agent's narrowing focus, absolute. */
const narrowed = new Map() 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. * 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 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 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 * place — this one only has to notice a root that has since moved out from
* under it. * 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|undefined} sessionId - the session.
* @param {string} sessionCwd - its resolved working directory. * @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. * @returns {string} the absolute facet root.
*/ */
function rootFor(sessionId, sessionCwd) { function rootFor(sessionId, sessionCwd, inherited) {
const declared = declaredScope(sessionId)?.focus 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) const agentFocus = sessionId === undefined ? undefined : narrowed.get(sessionId)
if (agentFocus === undefined) return base if (agentFocus !== undefined && within(agentFocus, base)) return agentFocus
if (agentFocus === base || agentFocus.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)) return agentFocus
return base return base
} }
@@ -162,7 +208,13 @@ export function createFacetPolicy(config) {
* resolve against. A declaration written relative to the session keeps the * resolve against. A declaration written relative to the session keeps the
* same meaning whatever the session is focused on. * 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. * @returns {FacetScope|undefined} the scope.
*/ */
function scopeFor(agent) { function scopeFor(agent) {
@@ -170,9 +222,13 @@ export function createFacetPolicy(config) {
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
const sessionCwd = resolve(cwd) const sessionCwd = resolve(cwd)
const sessionId = idOf(agent) 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 { return {
cwd: sessionCwd, cwd: sessionCwd,
root: rootFor(sessionId, sessionCwd), root,
extras: extrasFor(agent, sessionCwd), extras: extrasFor(agent, sessionCwd),
} }
} }
@@ -284,10 +340,22 @@ export function createFacetPolicy(config) {
return [...(grants.get(sessionId) ?? [])] 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) { function forget(sessionId) {
grants.delete(sessionId) grants.delete(sessionId)
narrowed.delete(sessionId) narrowed.delete(sessionId)
spawnedFrom.delete(sessionId)
if (![...spawnedFrom.values()].includes(sessionId)) roots.delete(sessionId)
} }
return { return {
@@ -325,6 +393,25 @@ function isPath(value) {
return typeof value === 'string' && value.trim() !== '' 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. * The session id of a calling agent, when it has one.
* *
+78
View File
@@ -241,6 +241,84 @@ test('an agent with no working directory is not scoped', async () => {
assert.deepEqual(await dispatch(captured, { name: 'read', args: { file_path: '/etc/hosts' } }), { kind: 'allow' }) assert.deepEqual(await dispatch(captured, { name: 'read', args: { file_path: '/etc/hosts' } }), { kind: 'allow' })
}) })
// ── delegated sessions ───────────────────────────────────────────────────────
//
// A session spawned or forked from a scoped one has no declaration of its own,
// and the user's declaration is keyed by session id. Without inheritance its
// facet would be its own working directory — wider than the conversation it came
// from, which is the one direction this plugin promises cannot happen.
/** A session that was spawned from another, as the harness records lineage. */
const childOf = (parentId, id = 'child-1', cwd = facet) => ({
session: { id, header: { cwd, parentSession: parentId } },
})
test('a session spawned from a scoped one starts inside the same facet', async () => {
const { captured } = harness({ config: { mode: 'ask', scopes: { 'session-1': { focus: 'inner' } } } })
// The parent's root is on the record as soon as it resolves a scope, which
// happens for every model request and every tool call — so before it can
// spawn anything.
assert.deepEqual(
await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: agent() }),
{ kind: 'allow' },
)
const child = childOf('session-1')
// Without inheritance this would be ordinary work for the child: its root
// would be the session directory, one level up from its parent's facet.
const decision = await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: child })
assert.equal(decision.kind, 'ask')
assert.match(decision.reason, /inner/)
// Inside the inherited facet is inside the child's scope, as it should be.
assert.deepEqual(
await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: child }),
{ kind: 'allow' },
)
})
test('inheritance holds at depth, and is not a way around a self-narrowing', async () => {
// No declaration: the user left the session at its directory, and the only
// thing that will keep a child inside `inner` is the inherited root.
const { captured } = harness({ config: { mode: 'deny' } })
await captured.tool.execute({ action: 'focus', paths: ['inner'] }, { agent: agent() })
const child = childOf('session-1')
const grandchild = childOf('child-1', 'grand-1')
// The child's own scope is resolved by its first call, which is what puts its
// root on the record for the grandchild to inherit.
for (const descendant of [child, grandchild]) {
assert.equal(
(await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: descendant })).kind,
'allow',
`${descendant.session.id} is inside the inherited facet`,
)
assert.equal(
(await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: descendant })).kind,
'deny',
`${descendant.session.id} cannot delegate its way back out of the narrowing`,
)
}
})
test('a declaration for the child itself stands over what it would inherit', async () => {
const { captured } = harness({
config: { mode: 'ask', scopes: { 'session-1': { focus: 'inner' }, 'child-1': { focus: '.' } } },
})
assert.deepEqual(
await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: agent() }),
{ kind: 'allow' },
)
// The user is the authority on this session too: a declaration made for the
// child is a statement about the child, and inheritance only answers the case
// where they made none.
assert.deepEqual(
await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: childOf('session-1') }),
{ kind: 'allow' },
)
})
test('a standing grant and a session grant both open the scope', async () => { test('a standing grant and a session grant both open the scope', async () => {
const standing = harness({ extraRoots: ['../../shared'] }) const standing = harness({ extraRoots: ['../../shared'] })
assert.deepEqual( assert.deepEqual(
+58
View File
@@ -187,3 +187,61 @@ test('junk in a declaration is ignored rather than thrown on', () => {
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/ok']) assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/ok'])
assert.equal(policy.scopeFor(agentAt('/w', 's2')).root, '/w') assert.equal(policy.scopeFor(agentAt('/w', 's2')).root, '/w')
}) })
// ── delegated sessions ───────────────────────────────────────────────────────
//
// A session spawned or forked from this one has no declaration of its own, and
// the user's declaration is keyed by session id. What stops it starting wider
// than its parent is the parent's own resolved root, recorded as the parent
// resolves a scope — which it does for every model request and every tool call,
// so the record is always there before it can spawn anything.
/** An agent whose header carries the lineage the harness writes for a child. */
const spawned = (id, parentSession, cwd = '/w') => ({
session: { id, header: { cwd, parentSession } },
})
test('a session spawned from a scoped one inherits its root, at any depth', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src/room' } } })
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
const child = spawned('c1', 's1')
assert.equal(policy.scopeFor(child).root, '/w/src/room')
// Relative paths still resolve from the child's own directory, as for anyone.
assert.equal(policy.scopeFor(child).cwd, '/w')
assert.equal(policy.scopeFor(spawned('g1', 'c1')).root, '/w/src/room')
})
test('a child is never wider than its parent is now, narrowing included', () => {
const policy = createFacetPolicy({ mode: 'deny' })
policy.narrowTo('s1', '/w/src/room')
// The narrowing is the agent's own decision; spawning a child is not a way to
// undo it, because the root the child inherits already includes it.
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
assert.equal(policy.scopeFor(spawned('c1', 's1')).root, '/w/src/room')
})
test('a declaration for the child itself stands over what it would inherit', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src/room' }, c1: { focus: '.' } } })
policy.scopeFor(agentAt('/w', 's1'))
// Inheritance answers the case where the user made no statement about this
// session; a statement they did make is the more specific one and stands.
assert.equal(policy.scopeFor(spawned('c1', 's1')).root, '/w')
})
test('a root is kept while a session that inherited it is still alive', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src/room' } } })
const child = spawned('c1', 's1')
policy.scopeFor(agentAt('/w', 's1'))
policy.scopeFor(child)
// Disposing the parent must not quietly widen the child that is still running.
policy.forget('s1')
assert.equal(policy.scopeFor(child).root, '/w/src/room')
// With nothing left to inherit it, the record goes too.
policy.forget('c1')
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
})