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 = [
+142 -12
View File
@@ -16,7 +16,7 @@
* exists to prevent.
*/
import { isAbsolute, resolve } from 'node:path'
import { isAbsolute, resolve, sep } from 'node:path'
/**
* The symbol cosmokit uses to mark a volatile config reference.
@@ -63,18 +63,78 @@ export function configValue(value) {
* `config` is the *resolved* composition config, so its volatile fields are
* references rather than values; every read below goes through
* {@link configValue} and happens per use. Holding the config and reading it lazily
* is what makes the settings card honest: `mode` and `extraRoots` are edited live
* in the Web UI, and a policy that snapshotted them here would keep answering
* with the values it was loaded with.
* is what makes the settings card honest: `mode`, `extraRoots` and `scopes` are
* edited live in the Web UI, and a policy that snapshotted them here would keep
* answering with the values it was loaded with.
*
* ## Two layers of scope, and only one of them the agent can move
*
* The session's scope is declared in two places on purpose, because the two
* audiences have opposite authority:
*
* * 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. The agent has no write path to it: nothing in this plugin exposes
* one, and a facet session carries no settings tool.
* * the **agent layer** is in memory — a narrowing `focus` override and the
* grants it asked for. Both can only ever *reduce* what the user layer
* allows, so an agent that goes wrong cannot widen its own scope.
*
* The effective root is the agent's narrowing while it still sits inside the
* user's root, and the user's root otherwise. That ordering matters when the
* user narrows after the agent has: the stale agent focus is dropped rather than
* honoured, because honouring it would put the session outside the scope the
* user had just declared.
*
* @param {object} config
* @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled.
* @param {readonly string[]} [config.extraRoots] - standing grants, resolved against each session's cwd.
* @param {readonly string[]} [config.extraRoots] - a deployment-wide seed of standing grants.
* @param {Record<string, {focus?: string, extras?: readonly string[]}>} [config.scopes] - per-session scope, keyed by session id.
* @returns {object} the `facetPolicy` service implementation.
*/
export function createFacetPolicy(config) {
/** @type {Map<string, string[]>} session id → granted paths, in the order granted. */
const grants = new Map()
/** @type {Map<string, string>} session id → the agent's narrowing focus, absolute. */
const narrowed = new Map()
/**
* What the user declared for one session, read fresh.
*
* @param {string|undefined} sessionId - the session to read.
* @returns {{focus?: string, extras?: readonly string[]}|undefined} the declaration.
*/
function declaredScope(sessionId) {
if (sessionId === undefined) return undefined
const all = configValue(config.scopes)
if (all === null || typeof all !== 'object') return undefined
const entry = all[sessionId]
return entry !== null && typeof entry === 'object' ? entry : undefined
}
/**
* The root in force for one session.
*
* The user's `focus` is resolved against the session directory, not against
* the process, so a relative declaration keeps its meaning if the workspace
* moves. The agent's narrowing is applied only while it is still inside that
* root; a plain prefix test is enough here because the guard's own containment
* check, which resolves symlinks, is what authorises a narrowing in the first
* place — this one only has to notice a root that has since moved out from
* under it.
*
* @param {string|undefined} sessionId - the session.
* @param {string} sessionCwd - its resolved working directory.
* @returns {string} the absolute facet root.
*/
function rootFor(sessionId, sessionCwd) {
const declared = declaredScope(sessionId)?.focus
const base = typeof declared === 'string' && declared.trim() !== '' ? resolve(sessionCwd, declared) : sessionCwd
const agentFocus = sessionId === undefined ? undefined : narrowed.get(sessionId)
if (agentFocus === undefined) return base
if (agentFocus === base || agentFocus.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)) return agentFocus
return base
}
/**
* The deployment's standing grants, read fresh on every call.
@@ -97,31 +157,77 @@ export function createFacetPolicy(config) {
* something arbitrary: `undefined` here means the guard has no opinion, which
* is the only safe reading of "we do not know where this agent is".
*
* `cwd` stays the session directory even when the root is narrower, because
* it is what relative paths — a grant, a `focus`, an `extraRoots` entry —
* resolve against. A declaration written relative to the session keeps the
* same meaning whatever the session is focused on.
*
* @param {{session?: {header?: {cwd?: unknown}, id?: unknown}}|undefined} agent - the calling agent.
* @returns {FacetScope|undefined} the scope.
*/
function scopeFor(agent) {
const cwd = agent?.session?.header?.cwd
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
const root = resolve(cwd)
return { cwd: root, root, extras: extrasFor(agent, root) }
const sessionCwd = resolve(cwd)
const sessionId = idOf(agent)
return {
cwd: sessionCwd,
root: rootFor(sessionId, sessionCwd),
extras: extrasFor(agent, sessionCwd),
}
}
/**
* Every path in scope beyond the root: standing grants first, then this
* session's, deduplicated so the prompt does not repeat itself.
* Every path in scope beyond the root: the deployment seed, then what the user
* declared for this session, then what the agent was granted — deduplicated so
* the prompt does not repeat itself.
*
* All three are kept rather than layered, because they answer different
* questions. The seed is "this deployment always needs it", the declaration is
* "this conversation needs it", and a grant is "the user allowed this call".
* Removing one from the list when a narrower one exists would mean a user
* editing their panel could silently drop a path the deployment depends on.
*
* @param {{session?: {id?: unknown}}|undefined} agent - the calling agent.
* @param {string} cwd - the resolved session directory.
* @returns {string[]} absolute paths.
*/
function extrasFor(agent, cwd) {
const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : undefined
const sessionId = idOf(agent)
const own = sessionId === undefined ? [] : grants.get(sessionId) ?? []
const all = [...standingRoots(), ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path)))
const declared = declaredScope(sessionId)?.extras
const listed = Array.isArray(declared) ? declared.filter(isPath) : []
const all = [...standingRoots(), ...listed, ...own].map((path) =>
isAbsolute(path) ? resolve(path) : resolve(cwd, path),
)
return [...new Set(all)]
}
/**
* Narrow one session's facet to a path the caller has already authorised.
*
* The containment check is the caller's, and deliberately so: only the guard
* holds `ctx.fs`, and deciding containment on canonical targets is the whole
* reason a symlink inside the facet does not open it. This records the answer;
* {@link rootFor} re-checks that the user has not since narrowed past it.
*
* @param {string} sessionId - the session being narrowed.
* @param {string} absolute - the absolute root to narrow to.
* @returns {string} the recorded focus.
*/
function narrowTo(sessionId, absolute) {
narrowed.set(sessionId, absolute)
return absolute
}
/**
* @param {string} sessionId - the session to read.
* @returns {string|undefined} the agent's narrowing for it, if any.
*/
function narrowFor(sessionId) {
return narrowed.get(sessionId)
}
/**
* @param {string} sessionId - the session being widened.
* @param {readonly string[]} paths - raw paths, resolved against `cwd`.
@@ -154,11 +260,19 @@ export function createFacetPolicy(config) {
}
/**
* Clear one session's agent-side scope: its grants and any narrowing it asked
* for.
*
* Deliberately not a widening. The user's declaration is not touched, so this
* returns the session to what the user set up in the panel — which may be
* narrower than where the agent had got to, and can never be wider.
*
* @param {string} sessionId - the session to clear.
* @returns {string[]} always empty.
*/
function reset(sessionId) {
grants.delete(sessionId)
narrowed.delete(sessionId)
return []
}
@@ -173,6 +287,7 @@ export function createFacetPolicy(config) {
/** Drop a finished session's grants. Called on `session/disposed`. */
function forget(sessionId) {
grants.delete(sessionId)
narrowed.delete(sessionId)
}
return {
@@ -183,11 +298,16 @@ export function createFacetPolicy(config) {
/** Prompt placements, carried here so the preset row needs no configuration of its own. */
sectionOrder: configValue(config.sectionOrder) ?? 100,
contextOrder: configValue(config.contextOrder) ?? 105,
/** The standing grants. A getter for the same reason as {@link mode}. */
/** The deployment-wide seed of standing grants. A getter for the same reason as {@link mode}. */
get standing() {
return standingRoots()
},
/** What the user declared for one session: their `focus` and `extras`. */
declaredFor: declaredScope,
scopeFor,
rootFor,
narrowTo,
narrowFor,
grant,
revoke,
reset,
@@ -204,3 +324,13 @@ export function createFacetPolicy(config) {
function isPath(value) {
return typeof value === 'string' && value.trim() !== ''
}
/**
* The session id of a calling agent, when it has one.
*
* @param {{session?: {id?: unknown}}|undefined} agent - the calling agent.
* @returns {string|undefined} the id.
*/
function idOf(agent) {
return typeof agent?.session?.id === 'string' ? agent.session.id : undefined
}
+4 -4
View File
@@ -195,12 +195,12 @@ export function facetContextText(input) {
// the reason this guard writes never reaches it: `serviceAsk` replaces a
// rejected ask with `the user rejected tool "…"`. So the binding is the one
// channel that can say what is true, and it names the mechanism that still
// works — `extraRoots` is configuration applied by the profile, not a grant
// asked for per conversation.
// works: the facet's context panel, which the user owns and the agent has no
// write path to.
const reach = !canAsk
? 'refused — approval prompts are off in this session, so `facet action=add` cannot be approved either; only the user can widen this, by adding the path to `extraRoots`'
? 'refused — this session has no approval channel, so `facet action=add` cannot be granted; ask the user to widen the facet from its context panel'
: mode === 'deny'
? 'refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope'
? 'refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted'
: 'put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again'
// The root is stated absolutely even though it is the session's own directory