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

+10 -2
View File
@@ -160,14 +160,22 @@ test('nothing is registered while the Host does not serve the namespace', async
assert.equal(seen.entry, undefined, 'but no card is registered for a namespace nobody serves')
})
test('the card stages exactly the fields the host marks volatile', async () => {
test('the card stages exactly the deployment fields the host marks volatile', async () => {
const { module: mod, staged } = await loadClient()
drive(mod)
const declared = Object.entries(Config.dict)
const volatileFields = Object.entries(Config.dict)
.filter(([, schema]) => schema.meta.volatile === true)
.map(([key]) => key)
// `scopes` is volatile too, but it is not a deployment field and must not be a
// control on the card: it is per-session state, written by the context panel
// and keyed by session id. Staging it here would render a dictionary blob a
// user could not sensibly edit, and would put the panel's store on the one
// surface that edits the deployment.
assert.ok(volatileFields.includes('scopes'), 'scopes must be volatile or the panel cannot write it')
const declared = volatileFields.filter((key) => key !== 'scopes')
// A field served but never staged is invisible in the UI; a field staged but
// never served makes `whileServed` withhold the whole card.
assert.deepEqual(
+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() }
+94
View File
@@ -93,3 +93,97 @@ test('prompt placements are carried, so the preset row needs no config', () => {
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).sectionOrder, 7)
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).contextOrder, 8)
})
// ── per-session scope ────────────────────────────────────────────────────────
//
// The declaration is the user's, written from the browser panel; the narrowing
// is the agent's, held in memory. Everything below is about which of the two
// wins, and the answer is always "the narrower one, and never a stale agent
// focus that the user has since narrowed past".
test('a declared focus narrows the root, and the session directory stays the base', () => {
const policy = createFacetPolicy({
mode: 'deny',
scopes: { s1: { focus: 'src/controllers/room' } },
})
const scope = policy.scopeFor(agentAt('/w', 's1'))
assert.equal(scope.root, '/w/src/controllers/room')
// `cwd` is still where relative paths resolve from — a declaration written
// against the session keeps its meaning whatever the session focuses on.
assert.equal(scope.cwd, '/w')
})
test('a declaration is read live, so the panel needs no remount', () => {
const scopes = {}
const policy = createFacetPolicy({ mode: 'deny', scopes })
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w')
// What a panel write does to the running reference.
scopes.s1 = { focus: 'src/module' }
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/module')
})
test('declared extras join the deployment seed and the session grants', () => {
const policy = createFacetPolicy({
mode: 'deny',
extraRoots: ['seed'],
scopes: { s1: { focus: 'src/room', extras: ['shared', '/abs/common'] } },
})
policy.grant('s1', ['granted'], '/w')
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, [
'/w/seed',
'/w/shared',
'/abs/common',
'/w/granted',
])
})
test('an agent narrowing is honoured while it stays inside the declared root', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src' } } })
policy.narrowTo('s1', '/w/src/room')
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
assert.equal(policy.narrowFor('s1'), '/w/src/room')
})
test('a narrowing the user has since narrowed past is dropped, not honoured', () => {
const scopes = { s1: { focus: 'src' } }
const policy = createFacetPolicy({ mode: 'deny', scopes })
policy.narrowTo('s1', '/w/src/room')
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room')
// The user narrows to a sibling: the agent's focus is now outside the scope
// they declared. Honouring it would put the session back where the user had
// just decided it should not be.
scopes.s1 = { focus: 'src/other' }
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/other')
})
test('reset and forget drop the agent side and leave the declaration alone', () => {
const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src', extras: ['shared'] } } })
policy.narrowTo('s1', '/w/src/room')
policy.grant('s1', ['granted'], '/w')
policy.reset('s1')
// Back to what the user declared — narrower than the session, never wider.
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src')
assert.deepEqual(policy.grantsFor('s1'), [])
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/shared'])
policy.narrowTo('s1', '/w/src/room')
policy.forget('s1')
assert.equal(policy.narrowFor('s1'), undefined)
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src')
})
test('junk in a declaration is ignored rather than thrown on', () => {
const policy = createFacetPolicy({
mode: 'deny',
scopes: { s1: { focus: ' ', extras: ['ok', '', 42, null] }, s2: 'not an object' },
})
assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w')
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/ok'])
assert.equal(policy.scopeFor(agentAt('/w', 's2')).root, '/w')
})
+6 -6
View File
@@ -82,7 +82,7 @@ test('facetContextText carries the binding that does change', () => {
deny,
'facet: /w/src/room\n' +
'granted outside it: src/shared\n' +
'outside the facet: refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope',
'outside the facet: refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted',
)
// The binding is the only half that can follow a live edit, so the two modes
@@ -97,14 +97,14 @@ test('facetContextText carries the binding that does change', () => {
test('a session with no approval channel says so instead of promising a question', () => {
// `approval: never` refuses every ask before an answerer is consulted, so the
// mode stops mattering: `ask` and `deny` both end in a refusal, and
// `facet action=add` cannot be approved either. `canAsk: false` is the one
// `facet action=add` cannot be granted either. `canAsk: false` is the one
// input that collapses them, and it has to name the mechanism that still
// works — `extraRoots` is configuration, not a per-conversation grant.
// works — the context panel, which the user owns.
for (const mode of ['ask', 'deny']) {
const text = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode, canAsk: false })
assert.match(text, /outside the facet: refused — approval prompts are off in this session/)
assert.match(text, /`facet action=add` cannot be approved either/)
assert.match(text, /`extraRoots`/)
assert.match(text, /outside the facet: refused — this session has no approval channel/)
assert.match(text, /`facet action=add` cannot be granted/)
assert.match(text, /context panel/)
assert.doesNotMatch(text, /put to the user as a question/)
}