304 lines
12 KiB
JavaScript
304 lines
12 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
|
|
/** @type {'ask'|'deny'|'off'} */
|
|
const mode = policy.mode
|
|
|
|
if (mode === '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
|
|
|
|
// 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.
|
|
ctx.systemPrompt.section({
|
|
name: 'facet-scope',
|
|
order: Number(sectionOrder),
|
|
text: facetRuleText({ mode }),
|
|
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 })
|
|
},
|
|
})
|
|
|
|
if (config.tool) ctx.tools.register(facetTool(ctx, policy, mode))
|
|
|
|
// `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.
|
|
* @param {'ask'|'deny'|'off'} mode - the deployment's enforcement mode.
|
|
*/
|
|
function facetTool(ctx, policy, mode) {
|
|
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,
|
|
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')
|
|
}
|