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:
NotExpectedYet committed 2026-09-27 06:10:53 +01:00
1 parent f36d46efa1
commit d9caf4eeec
8 files changed
+431 -52

No files matched your search

+61 -5
View File
@@ -107,6 +107,7 @@ before(async () => {
root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-facet-')))
facet = join(root, 'src', 'controllers', 'room')
await mkdir(facet, { recursive: true })
await mkdir(join(facet, 'inner'), { recursive: true })
await mkdir(join(root, 'src', 'controllers', 'other'), { recursive: true })
await mkdir(join(root, 'src', 'shared'), { recursive: true })
await writeFile(join(facet, 'index.ts'), 'export {}\n')
@@ -293,9 +294,10 @@ test('the binding refuses honestly when the session has no approval channel', ()
const { captured } = harness({ mode: 'ask', approval: 'never' })
const text = captured.context.text({ agent: agent() })
assert.match(text, /approval prompts are off in this session/)
assert.match(text, /cannot be approved either/)
assert.match(text, /`extraRoots`/)
assert.match(text, /no approval channel/)
assert.match(text, /cannot be granted/)
// It names the mechanism that still works, which is the panel the user owns.
assert.match(text, /context panel/)
// The promise `ask` makes cannot be kept here, so it must not be made.
assert.doesNotMatch(text, /put to the user as a question/)
@@ -308,14 +310,14 @@ test('an approval channel keeps the promise, and a missing service counts as non
// No approval service at all: the tool pipeline denies an ask it cannot route,
// so the binding must not offer one.
const without = harness({ mode: 'ask' })
assert.match(without.captured.context.text({ agent: agent() }), /approval prompts are off in this session/)
assert.match(without.captured.context.text({ agent: agent() }), /no approval channel/)
})
test('deny mode still names the way that works when there is no channel', () => {
const { captured } = harness({ mode: 'deny', approval: 'never' })
const text = captured.context.text({ agent: agent() })
assert.match(text, /refused/)
assert.match(text, /`extraRoots`/)
assert.match(text, /context panel/)
})
test('the facet tool reports the scope', async () => {
@@ -347,6 +349,60 @@ test('the facet tool grants, narrows and clears', async () => {
assert.deepEqual(policy.grantsFor('session-1'), [])
})
test('the facet tool narrows inside the facet and refuses a folder outside it', async () => {
const { captured, policy } = harness()
const exec = { agent: agent() }
// Narrowing is the one scope change the agent may make unasked: it can only
// ever reduce what is visible, so there is nothing for a user to approve.
const narrowed = await captured.tool.execute({ action: 'focus', paths: ['inner'] }, exec)
assert.equal(narrowed.root, join(facet, 'inner'))
assert.equal(narrowed.facet, join(facet, 'inner'))
assert.equal(policy.scopeFor(agent()).root, join(facet, 'inner'))
// A folder outside the scope already in force is a widening by another name,
// and widening is the user's decision, taken in their panel.
await assert.rejects(
() => captured.tool.execute({ action: 'focus', paths: ['../../shared'] }, exec),
/outside the current facet/,
)
await assert.rejects(
() => captured.tool.execute({ action: 'focus', paths: ['../other'] }, exec),
/outside the current facet/,
)
// A refused narrowing must not have moved anything.
assert.equal(policy.scopeFor(agent()).root, join(facet, 'inner'))
})
test('the facet tool needs exactly one folder to focus on', async () => {
const { captured } = harness()
const exec = { agent: agent() }
await assert.rejects(() => captured.tool.execute({ action: 'focus' }, exec), /exactly one folder/)
await assert.rejects(
() => captured.tool.execute({ action: 'focus', paths: ['inner', '../other'] }, exec),
/exactly one folder/,
)
})
test('reset lands on the scope the user declared, never back on the session directory', async () => {
const { captured, policy } = harness({
config: { mode: 'deny', scopes: { 'session-1': { focus: 'src/controllers/room' } } },
})
const exec = { agent: agent(root) }
// The user declared this conversation to be about the room controller, so the
// session is already narrower than the directory it was started in.
assert.equal(policy.scopeFor(agent(root)).root, facet)
await captured.tool.execute({ action: 'focus', paths: ['src/controllers/room/inner'] }, exec)
assert.equal(policy.scopeFor(agent(root)).root, join(facet, 'inner'))
// Clearing the agent's side has to land on the user's declaration. Landing on
// the session directory would make `reset` a widening.
await captured.tool.execute({ action: 'reset' }, exec)
assert.equal(policy.scopeFor(agent(root)).root, facet)
})
test('the facet tool refuses a call it cannot act on', async () => {
const { captured } = harness()
const exec = { agent: agent() }