Files
dsh-facet/lib/guard.js
T
NotExpectedYet a1c8ecb1e8
Release / release (push) Successful in 6s
fix: say so when a session has no approval channel to ask through
`ask` mode promises 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`, and the
approval service's `decide()` then returns "rejected" before consulting any
answerer. The call is refused, `facet action=add` is refused with it — widening
is itself a question — and the scope can never be widened.

The model is told none of that. `serviceAsk` replaces a rejected ask with
`the user rejected tool \"…\"`, discarding the reason this guard wrote, so the
binding is the only channel left that can state what is true. It now reads the
session's effective approval policy and, when there is no channel, says the call
was refused rather than asked and names the mechanism that still works:
`extraRoots` is configuration applied by the profile, not a grant asked for per
conversation.

A missing approval service counts as no channel (the tool pipeline denies an ask
it cannot route); an unreadable policy counts as a channel, so a transient
failure never tells a user to go and edit their configuration.
2026-09-26 09:02:48 +01:00

354 lines
14 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, 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`.
*
* @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, 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.',
parameters: {
action: {
type: 'string',
required: true,
description: 'One of: status, 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.',
},
},
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\``
: !['status', 'add', 'remove', 'reset'].includes(action)
? `facet: unknown action "${action}"; expected status, 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)
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.' }
: {}),
}
},
})
}
/** 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')
}