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

+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' })
})
// ── 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 () => {
const standing = harness({ extraRoots: ['../../shared'] })
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.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')
})