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

+63 -11
View File
@@ -246,13 +246,18 @@ function requestedPaths(args) {
}
/**
* The `facet` tool: read the scope, or ask to widen it.
* The `facet` tool: read the scope, narrow it, or ask to widen it.
*
* `add` does not enforce anything itself — the pre-execute listener has already
* put the question to the user, and the body only runs if they said yes. That
* split is deliberate: the approval is a property of the *call*, so it holds
* even if this tool is later replaced or dispatched from `run_code`.
*
* `focus` is the one action this tool performs without asking, and it is the
* only one that can only ever *reduce* the scope. That asymmetry is the whole
* design: an agent may decide a conversation is about one folder, and may not
* decide it is about more.
*
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context.
* @param {object} policy - the `facetPolicy` service.
*/
@@ -260,21 +265,23 @@ function facetTool(ctx, policy) {
return defineTool({
name: FACET_TOOL,
description:
'Read this session\u2019s facet scope, or ask the user to grant an extra path. A facet is the folder the ' +
'conversation was started in; the session is scoped to it. Use `status` to see the current scope. Use `add` ' +
'only when you actually need a file outside the facet — it asks the user for permission, and the grant lasts ' +
'for the rest of the conversation. Never call `add` for a path you merely expect to need.',
'Read this session\u2019s facet scope, narrow it to one folder, or ask the user for an extra path. A facet is ' +
'the folder the conversation is scoped to; `status` reports it. Use `focus` when the work has settled on one ' +
'folder inside the facet — narrowing is always allowed and takes effect at once. Use `add` only when you ' +
'actually need a file outside the facet: it asks the user for permission, and the grant lasts for the rest of ' +
'the conversation. Never call `add` for a path you merely expect to need. Where the deployment has no approval ' +
'channel, `add` cannot be granted at all and the user widens the scope from the facet panel in their browser.',
parameters: {
action: {
type: 'string',
required: true,
description: 'One of: status, add, remove, reset.',
description: 'One of: status, focus, add, remove, reset.',
},
paths: {
type: 'array',
items: { type: 'string' },
description:
'Paths to grant, relative to the session directory or absolute. Required for add and remove; ignored otherwise.',
'Paths, relative to the session directory or absolute. Required for add and remove; for focus, exactly one folder inside the current facet.',
},
},
output: {
@@ -307,13 +314,16 @@ function facetTool(ctx, policy) {
const problem =
(action === 'add' || action === 'remove') && requested.length === 0
? `facet: action "${action}" needs \`paths\``
: !['status', 'add', 'remove', 'reset'].includes(action)
? `facet: unknown action "${action}"; expected status, add, remove or reset`
: undefined
: action === 'focus' && requested.length !== 1
? 'facet: action "focus" needs exactly one folder'
: !['status', 'focus', 'add', 'remove', 'reset'].includes(action)
? `facet: unknown action "${action}"; expected status, focus, add, remove or reset`
: undefined
if (problem !== undefined) throw new Error(problem)
const before = policy.grantsFor(sessionId)
if (action === 'add') policy.grant(sessionId, requested, cwd)
if (action === 'focus') policy.narrowTo(sessionId, await narrowing(ctx, policy, exec, requested[0]))
else if (action === 'add') policy.grant(sessionId, requested, cwd)
else if (action === 'remove') policy.revoke(sessionId, requested, cwd)
else if (action === 'reset') policy.reset(sessionId)
@@ -340,6 +350,48 @@ function facetTool(ctx, policy) {
})
}
/**
* Resolve one `focus` request to the canonical root it asks for.
*
* Refuses anything the scope already in force does not contain, because that
* would be a widening by another name — and widening is the user's decision, not
* the agent's. The containment test is the filesystem provider's, on canonical
* targets, for the same reason the guard uses it: a symlink inside the facet
* that points outside it must not become a way through.
*
* The canonical key is what gets recorded, so a later `scopeFor` compares like
* with like. Where the user declared a root through a symlink, the two spellings
* may not share a prefix; the narrowing is then dropped rather than trusted,
* which loses the narrowing and never the boundary.
*
* @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context, holding `fs`.
* @param {object} policy - the `facetPolicy` service.
* @param {import('@deepseek-ai/dsh-tools').ToolExecution} exec - the running call.
* @param {string} raw - the folder the model named.
* @returns {Promise<string>} the absolute root to narrow to.
*/
async function narrowing(ctx, policy, exec, raw) {
const scope = policy.scopeFor(exec.agent)
if (scope === undefined) throw new Error('facet: this session has no working directory, so there is nothing to narrow')
let target
let boundary
try {
target = await ctx.fs.resolve(raw, { cwd: scope.cwd, signal: exec.signal })
boundary = await ctx.fs.resolve(scope.root, { cwd: scope.cwd, signal: exec.signal })
} catch {
throw new Error(`facet: cannot resolve "${raw}", so the facet cannot be narrowed to it`)
}
if (target.targetKey !== boundary.targetKey && !ctx.fs.contains(boundary, target)) {
throw new Error(
`facet: "${raw}" is outside the current facet (${scope.root}). Narrowing cannot leave the scope already in force; ` +
'ask the user to widen it from the facet panel if the work really has moved.',
)
}
return target.targetKey
}
/** Model-facing text for one `facet` result. Pure. */
function renderFacet(value) {
const lines = [