From 8ab32996944f2adf5684ef9af752c72a242a2c90 Mon Sep 17 00:00:00 2001 From: NotExpectedYet Date: Sun, 27 Sep 2026 07:43:03 +0100 Subject: [PATCH] fix: a delegated session inherits the facet it was spawned under MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 19 +++++++-- lib/policy.js | 101 +++++++++++++++++++++++++++++++++++++++++--- test/guard.test.js | 78 ++++++++++++++++++++++++++++++++++ test/policy.test.js | 58 +++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index bf53df4..1009037 100644 --- a/README.md +++ b/README.md @@ -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 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**, 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 @@ -237,7 +246,7 @@ Honest boundaries, because each one is a decision rather than an oversight: 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 (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 @@ -245,8 +254,10 @@ Verified: 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 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 - every `facet` result satisfies the tool's own declared output schema. + is refused when the directory it defaults to is outside the facet, that a + 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 profile: `dsh --profile web --patch ./cordis.patch.yml --patch ./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 presets/facet.patch.yml the `facet` agent preset 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 diff --git a/lib/policy.js b/lib/policy.js index fcd84f3..7a16486 100644 --- a/lib/policy.js +++ b/lib/policy.js @@ -97,6 +97,29 @@ export function createFacetPolicy(config) { const grants = new Map() /** @type {Map} 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} 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} + */ + 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. * diff --git a/test/guard.test.js b/test/guard.test.js index 28fb36e..2c2cfe7 100644 --- a/test/guard.test.js +++ b/test/guard.test.js @@ -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( diff --git a/test/policy.test.js b/test/policy.test.js index 72281c4..22019ea 100644 --- a/test/policy.test.js +++ b/test/policy.test.js @@ -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') +})