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:
1 parent
f36d46efa1
commit
d9caf4eeec
8 files changed
+431
-52
No files matched your search
+61
-5
@@ -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() }
|
||||
|
||||
Reference in new issue
Block a user