Files
dsh-facet/lib/guard.js
T
NotExpectedYet a9a25490db fix: judge a search that names no path against the directory it would use
The guard's rule was that a call naming no path is not judged, because a tool
that names no path cannot leave the facet. That is true of a tool with no path
argument and false of three that have one with a default: `grep` and `glob`
document theirs as "Defaults to the session workspace", and `recall` falls back
to a directory its own marker search picks. On any session whose facet is
narrower than its directory — every session whose focus was set from the panel
rather than by starting the conversation in the folder — a bare `grep` searches
outside the facet and returns what it finds.

An absent argument is not an absent path; it is the default one. The three tools
are now judged against the directory they would have searched, through the same
containment test that decides every other call, and the refusal is its own
sentence rather than the "that path is outside" one: the path at issue is one the
model never wrote, so the remedy is a second argument, not a grant.

A tool with no path argument at all is still left alone. `bash` is still the
documented way out, and the first deployment running this plugin confirmed it is
a real one: an agent scoped to one module used a shell command to parse a config
file outside its facet, having been told in the same prompt not to. That is what
this plugin is for, and what it cannot do is stated in the README rather than
implied.
2026-09-27 07:42:54 +01:00

421 lines
18 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,
IMPLICIT_SESSION_PATHS,
implicitSearchReason,
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) {
// A tool that names no path is judged against the path it will actually
// use. For most of them that is nothing — a tool with no path argument
// cannot leave the facet. For the ones whose own default is a directory it
// is the session directory, which a narrowed facet does not contain, and
// "no opinion" there would hand the model a read the guard exists to
// refuse. The containment test is the same one; only the path is ours
// rather than the model's.
if (!IMPLICIT_SESSION_PATHS.includes(exec.name)) return undefined
const verdict = await inside(scope, scope.cwd, exec.signal)
if (verdict.inside) return undefined
const kind = mode === 'deny' ? 'deny' : 'ask'
return { kind, reason: implicitSearchReason({ cwd: scope.cwd, root: scope.root }) }
}
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')
}