Files
dsh-facet/lib/guard.js
T
NotExpectedYet d9caf4eeec 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.
2026-09-27 06:10:53 +01:00

406 lines
17 KiB
JavaScript

/**
* dsh-facet — the enforcement half, mounted inside the `facet` agent preset.
*
* Three contributions, and each exists because a different audience needs to
* know the scope:
*
* 1. a **rule** in the system prompt — invariant for the life of the session;
* 2. a **binding** in the runtime context — which folder, what is granted,
* which changes as grants are made;
* 3. a **guard** on the tool pipeline — the part that does not depend on the
* model agreeing.
*
* ## Why the rule and the binding are in different places
*
* The system prompt is the expensive, stable half of a request: changing it
* invalidates the prompt cache for the whole conversation. The rule never
* changes, so it belongs there. The binding changes the moment a grant is made,
* so it goes in the runtime-context snapshot — the same treatment the sandbox
* policy and the approval policy already get, and for the same reason. Putting
* the binding in the system prompt would mean paying a full-context cache miss
* for every `facet add`.
*
* ## Why the guard asks rather than refuses
*
* A facet is a window, not a relevance filter: in the project this was written
* against, 41.6% of the code sits outside every module. A wall that cannot be
* opened would not confine the agent, it would only make it wrong — and an agent
* that cannot get at a shared type does not stop needing it, it goes looking for
* another route. So the default is a question, and the answer is recorded for
* the rest of the conversation.
*/
import { defineTool } from '@deepseek-ai/dsh-tools'
import Schema from '@deepseek-ai/schemastery'
import {
FACET_TOOL,
facetContextText,
facetRuleText,
isWideningCall,
outsideReason,
pathArgumentsFor,
widenReason,
} from './scope.js'
export const name = 'facet-guard'
/**
* `facetPolicy` is the host row's published policy. `fs` is required rather than
* probed: containment is decided on canonical filesystem targets, and a guard
* that cannot canonicalise cannot tell `/a/b` from `/a/bc`, so without it the
* honest behaviour is not to mount.
*/
export const inject = ['facetPolicy', 'tools', 'systemPrompt', 'fs']
export const Config = Schema.object({
/** Register the `facet` tool. On by default; the guard works without it. */
tool: Schema.boolean().default(true),
})
/**
* @param {import('@deepseek-ai/cordis').Context} ctx - preset-scoped context.
* @param {{tool: boolean}} config - validated {@link Config}.
*/
export function apply(ctx, config) {
const policy = ctx.facetPolicy
// Read at the point of use, never captured. `mode` is a volatile field, so a
// settings edit updates the policy's reference in place and deliberately does
// not re-apply this plugin; a `const mode` here would answer with the value
// the session was loaded with, for the life of the session.
const modeNow = () => policy.mode
/**
* Whether an approval request from this session can reach a person.
*
* `ask` mode is a promise that an out-of-facet call is *put to the user*, and
* that promise needs a channel. A session can be composed without one: the
* `danger-full-access` permission preset sets `approval: never`, under which
* the approval service's `decide()` returns "rejected" before consulting any
* answerer. An ask is then a denial, and `facet action=add` — itself an ask —
* is denied with it, so the scope can never be widened while the model is told
* only `the user rejected tool "…"`. Naming that is the binding's job; see
* `facetContextText`.
*
* A missing service is a definite no (the tool pipeline denies an ask it
* cannot route). An unreadable policy is treated as a yes, so a transient
* failure does not tell a user to go and edit their configuration.
*
* @param {{session?: object}|undefined} agent - the agent whose session supplies the policy.
* @returns {boolean} whether an ask can reach a person.
*/
const canAsk = (agent) => {
const approval = ctx.get('approval')
if (approval === undefined || agent?.session === undefined) return false
try {
return approval.effectivePolicy(agent.session) !== 'never'
} catch {
return true
}
}
// `off` is the one part that stays a mount-time decision. Deciding it here
// means an `off` deployment contributes no rule, no binding and no tool —
// which is the point of the mode. The cost is that turning it *on* later needs
// a reload, because a volatile-only edit never re-runs this function. `ask`
// and `deny` swap freely in both directions.
if (modeNow() === 'off') return
const sectionOrder = policy.sectionOrder ?? 100
const contextOrder = policy.contextOrder ?? 105
// Resolving the facet root costs a realpath per call and its answer cannot
// change within a session, so it is memoised. The key set is bounded by the
// standing grants plus whatever this session has been granted by hand.
/** @type {Map<string, object>} */
const targets = new Map()
const targetFor = async (path, cwd, signal) => {
const key = `${cwd}\u0000${path}`
const cached = targets.get(key)
if (cached !== undefined) return cached
const target = await ctx.fs.resolve(path, { cwd, signal })
targets.set(key, target)
return target
}
/**
* The verdict on one path, carrying the path worth showing a reader.
*
* A path that cannot be resolved is treated as inside: `resolve` fails on a
* malformed path, and the tool being guarded will report that far better than
* this guard can. Failing closed here would turn "you typed the path wrong"
* into a permission problem, which is a worse answer and a more confusing one.
*
* @param {{cwd: string, root: string, extras: string[]}} scope - the session's scope.
* @param {string} raw - the path the model named.
* @param {AbortSignal|undefined} signal - the call's cancellation signal.
* @returns {Promise<{inside: boolean, display: string}>} the verdict.
*/
const inside = async (scope, raw, signal) => {
let target
try {
target = await targetFor(raw, scope.cwd, signal)
} catch {
return { inside: true, display: raw }
}
// `resolve` canonicalises, so the question the user is asked names a real
// file rather than whatever form the model happened to choose.
const display = target.displayPath ?? raw
for (const boundary of [scope.root, ...scope.extras]) {
try {
if (ctx.fs.contains(await targetFor(boundary, scope.cwd, signal), target)) return { inside: true, display }
} catch {
// A boundary that cannot be resolved grants nothing; the root is the
// only one that must hold, and a session always has one.
}
}
return { inside: false, display }
}
/**
* The guard's opinion on one call, or `undefined` for no opinion.
*
* @param {import('@deepseek-ai/dsh-tools').ToolExecution} exec - the call about to dispatch.
* @returns {Promise<{kind: 'ask'|'deny', reason: string}|undefined>} the decision.
*/
const classify = async (exec) => {
if (exec.signal?.aborted) return undefined
const mode = modeNow()
if (mode === 'off') return undefined
// Widening is the one act that always needs the user, whatever the mode:
// it is explicit, deliberate, and the only thing standing between a scoped
// agent and an unscoped one.
if (isWideningCall(exec.name, exec.arguments)) {
return { kind: 'ask', reason: widenReason(requestedPaths(exec.arguments)) }
}
const scope = policy.scopeFor(exec.agent)
if (scope === undefined) return undefined
const paths = pathArgumentsFor(exec.name, exec.arguments)
if (paths.length === 0) return undefined
for (const raw of paths) {
const verdict = await inside(scope, raw, exec.signal)
if (verdict.inside) continue
const kind = mode === 'deny' ? 'deny' : 'ask'
return {
kind,
reason: outsideReason({ cwd: scope.cwd, path: verdict.display, root: scope.root, mode: kind }),
}
}
return undefined
}
// The rule. `interpolate: false` because every value in it is already
// literal; there is nothing here for the harness to substitute. It carries no
// `mode`, because a section is registered once and cannot follow a live edit —
// see `facetRuleText`.
ctx.systemPrompt.section({
name: 'facet-scope',
order: Number(sectionOrder),
text: facetRuleText(),
interpolate: false,
})
// The binding, re-materialised only when it changes.
ctx.systemPrompt.context({
name: 'facet-binding',
order: Number(contextOrder),
// Total, because this runs inside prompt assembly: a provider that throws
// takes the whole assembly with it, and "no context" is not a reason to
// fail a model request.
text: (context) => {
const scope = policy.scopeFor(context?.agent)
if (scope === undefined) return ''
return facetContextText({
cwd: scope.cwd,
root: scope.root,
extras: scope.extras,
mode: modeNow(),
canAsk: canAsk(context?.agent),
})
},
})
if (config.tool) ctx.tools.register(facetTool(ctx, policy))
// `next()` first, so an earlier policy plugin keeps its say: this listener
// only ever adds a question or a refusal to a call that was otherwise allowed.
ctx.on('tools/pre-execute', async (exec, next) => {
const inner = await next()
if (inner.kind !== 'allow') return inner
return (await classify(exec)) ?? inner
})
}
/** The paths a widening call is asking for, in the shapes models actually send. */
function requestedPaths(args) {
if (args === null || typeof args !== 'object') return []
const record = /** @type {Record<string, unknown>} */ (args)
const raw = Array.isArray(record.paths) ? record.paths : typeof record.path === 'string' ? [record.path] : []
return raw.filter((path) => typeof path === 'string' && path.trim() !== '').map((path) => path.trim())
}
/**
* 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.
*/
function facetTool(ctx, policy) {
return defineTool({
name: FACET_TOOL,
description:
'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, focus, add, remove, reset.',
},
paths: {
type: 'array',
items: { type: 'string' },
description:
'Paths, relative to the session directory or absolute. Required for add and remove; for focus, exactly one folder inside the current facet.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
action: { type: 'string', required: true },
ok: { type: 'boolean', required: true },
root: { type: 'string', required: true },
facet: { type: 'string', required: true },
mode: { type: 'string', required: true },
standing: { type: 'array', items: { type: 'string' }, required: true },
granted: { type: 'array', items: { type: 'string' }, required: true },
scope: { type: 'array', items: { type: 'string' }, required: true },
note: { type: 'string' },
},
},
render: (_args, value) => [{ type: 'text', text: renderFacet(value) }],
},
async execute(args, exec) {
const cwd = exec.agent?.session?.header?.cwd
if (typeof cwd !== 'string' || cwd.trim() === '') {
throw new Error('facet: this session has no working directory, so it has no facet to report')
}
const sessionId = String(exec.agent.session.id)
const requested = requestedPaths(args)
const action = String(args.action ?? '').trim().toLowerCase()
const problem =
(action === 'add' || action === 'remove') && requested.length === 0
? `facet: action "${action}" needs \`paths\``
: 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 === '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)
const scope = policy.scopeFor(exec.agent)
const granted = policy.grantsFor(sessionId)
return {
action,
ok: true,
root: scope.root,
facet: scope.root,
// Read here rather than captured at mount, so `status` reports the mode
// in force now instead of the one this session started under.
mode: String(policy.mode),
standing: policy.standing,
granted,
scope: scope.extras,
// Granting is idempotent, so an `add` that changed nothing says so
// rather than reporting success as though the scope had moved.
...(action === 'add' && granted.length === before.length
? { note: 'Already granted; the scope is unchanged.' }
: {}),
}
},
})
}
/**
* 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 = [
`facet: ${value.root}`,
`mode: ${value.mode}`,
value.standing.length > 0 ? `standing grants: ${value.standing.join(', ')}` : 'standing grants: none',
value.granted.length > 0 ? `granted this session: ${value.granted.join(', ')}` : 'granted this session: none',
]
if (value.note !== undefined) lines.push(value.note)
return lines.join('\n')
}