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
+63
-11
@@ -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 = [
|
||||
|
||||
Reference in new issue
Block a user