/** * 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} */ 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} */ (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} 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') }