init
This commit is contained in:
commit
0858862cda
17 files changed
+2189
No files matched your search
+303
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* The deployment half of the facet: what is always in scope, and what one
|
||||
* session has been granted.
|
||||
*
|
||||
* Split from {@link ./scope.js} because these two halves fail differently. The
|
||||
* scope algebra is pure and total — it must hold for any input, including a
|
||||
* malformed tool call. This module holds mutable state keyed by session, and its
|
||||
* mistakes are leaks: a grant that outlives its session, or a path kept in a
|
||||
* form that no longer resolves. Keeping the state here and the rules there means
|
||||
* the rules can be tested exhaustively without standing up a session.
|
||||
*
|
||||
* A grant is deliberately ephemeral. It lives for the conversation that asked
|
||||
* for it and is not written anywhere, because "this session may also see
|
||||
* `src/shared`" is a statement about one conversation, and persisting it would
|
||||
* quietly widen every future one — which is the failure mode the whole plugin
|
||||
* exists to prevent.
|
||||
*/
|
||||
|
||||
import { isAbsolute, resolve } from 'node:path'
|
||||
|
||||
/**
|
||||
* @typedef {object} FacetScope
|
||||
* @property {string} cwd - the session working directory, absolute.
|
||||
* @property {string} root - the facet root; the session's own folder.
|
||||
* @property {string[]} extras - absolute granted paths outside the root.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build the policy for one deployment.
|
||||
*
|
||||
* @param {object} config
|
||||
* @param {'ask'|'deny'|'off'} config.mode - how an out-of-scope call is handled.
|
||||
* @param {readonly string[]} [config.extraRoots] - standing grants, resolved against each session's cwd.
|
||||
* @returns {object} the `facetPolicy` service implementation.
|
||||
*/
|
||||
export function createFacetPolicy(config) {
|
||||
/** @type {Map<string, string[]>} session id → granted paths, in the order granted. */
|
||||
const grants = new Map()
|
||||
const standing = Array.isArray(config.extraRoots) ? config.extraRoots.filter(isPath) : []
|
||||
|
||||
/**
|
||||
* The scope of one agent, or `undefined` when it has none.
|
||||
*
|
||||
* An agent without a working directory is not scoped rather than scoped to
|
||||
* something arbitrary: `undefined` here means the guard has no opinion, which
|
||||
* is the only safe reading of "we do not know where this agent is".
|
||||
*
|
||||
* @param {{session?: {header?: {cwd?: unknown}, id?: unknown}}|undefined} agent - the calling agent.
|
||||
* @returns {FacetScope|undefined} the scope.
|
||||
*/
|
||||
function scopeFor(agent) {
|
||||
const cwd = agent?.session?.header?.cwd
|
||||
if (typeof cwd !== 'string' || cwd.trim() === '') return undefined
|
||||
const root = resolve(cwd)
|
||||
return { cwd: root, root, extras: extrasFor(agent, root) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every path in scope beyond the root: standing grants first, then this
|
||||
* session's, deduplicated so the prompt does not repeat itself.
|
||||
*
|
||||
* @param {{session?: {id?: unknown}}|undefined} agent - the calling agent.
|
||||
* @param {string} cwd - the resolved session directory.
|
||||
* @returns {string[]} absolute paths.
|
||||
*/
|
||||
function extrasFor(agent, cwd) {
|
||||
const sessionId = typeof agent?.session?.id === 'string' ? agent.session.id : undefined
|
||||
const own = sessionId === undefined ? [] : grants.get(sessionId) ?? []
|
||||
const all = [...standing, ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path)))
|
||||
return [...new Set(all)]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} sessionId - the session being widened.
|
||||
* @param {readonly string[]} paths - raw paths, resolved against `cwd`.
|
||||
* @param {string} cwd - the session directory.
|
||||
* @returns {string[]} the resulting grant list.
|
||||
*/
|
||||
function grant(sessionId, paths, cwd) {
|
||||
const resolved = paths
|
||||
.filter(isPath)
|
||||
.map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path)))
|
||||
const next = [...new Set([...(grants.get(sessionId) ?? []), ...resolved])]
|
||||
grants.set(sessionId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} sessionId - the session being narrowed.
|
||||
* @param {readonly string[]} paths - raw paths to drop.
|
||||
* @param {string} cwd - the session directory.
|
||||
* @returns {string[]} the resulting grant list.
|
||||
*/
|
||||
function revoke(sessionId, paths, cwd) {
|
||||
const drop = new Set(
|
||||
paths.filter(isPath).map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path))),
|
||||
)
|
||||
const next = (grants.get(sessionId) ?? []).filter((path) => !drop.has(path))
|
||||
if (next.length === 0) grants.delete(sessionId)
|
||||
else grants.set(sessionId, next)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} sessionId - the session to clear.
|
||||
* @returns {string[]} always empty.
|
||||
*/
|
||||
function reset(sessionId) {
|
||||
grants.delete(sessionId)
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} sessionId - the session to read.
|
||||
* @returns {string[]} its own grants, without the standing ones.
|
||||
*/
|
||||
function grantsFor(sessionId) {
|
||||
return [...(grants.get(sessionId) ?? [])]
|
||||
}
|
||||
|
||||
/** Drop a finished session's grants. Called on `session/disposed`. */
|
||||
function forget(sessionId) {
|
||||
grants.delete(sessionId)
|
||||
}
|
||||
|
||||
return {
|
||||
mode: config.mode,
|
||||
/** Prompt placements, carried here so the preset row needs no configuration of its own. */
|
||||
sectionOrder: config.sectionOrder ?? 100,
|
||||
contextOrder: config.contextOrder ?? 105,
|
||||
standing: [...standing],
|
||||
scopeFor,
|
||||
grant,
|
||||
revoke,
|
||||
reset,
|
||||
grantsFor,
|
||||
forget,
|
||||
/** Session ids currently holding a grant — for tests and diagnostics. */
|
||||
get sessions() {
|
||||
return [...grants.keys()]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {unknown} value @returns {value is string} */
|
||||
function isPath(value) {
|
||||
return typeof value === 'string' && value.trim() !== ''
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* The facet scope: which part of a project one session is allowed to see.
|
||||
*
|
||||
* ## Why the scope is the session's own folder
|
||||
*
|
||||
* A multi-faceted system is built from modules that look alike. In the project
|
||||
* this was written against there are fourteen controllers — the same idioms, the
|
||||
* same naming, the same shape — and a conversation about one of them that can
|
||||
* see all fourteen is a conversation that keeps having to work out which one it
|
||||
* is looking at. Narrowing it to the one being worked on removes the
|
||||
* confusability rather than trying to steer through it.
|
||||
*
|
||||
* The scope is therefore not configured. It is **where the session already is**:
|
||||
* the session's working directory, which the harness records immutably at
|
||||
* creation. Starting a conversation in `src/controllers/room/occupancy` makes
|
||||
* that controller the world the agent sees; nothing has to be declared, and a
|
||||
* session started at the project root is simply not scoped, which is the honest
|
||||
* answer for a conversation that is genuinely about the whole project.
|
||||
*
|
||||
* ## Why scope is additive rather than exclusive
|
||||
*
|
||||
* A facet is a window, not a relevance filter. In the same project 41.6% of the
|
||||
* code — shared types, services, infrastructure — sits outside every controller,
|
||||
* so a wall that cannot be opened would not confine the agent, it would only
|
||||
* make it wrong. Scope is therefore a *default*, and the way past it is an
|
||||
* explicit grant: `facet action=add` records a path for the rest of the session,
|
||||
* with the user's approval. Nothing here tries to infer that a needed file is
|
||||
* needed.
|
||||
*
|
||||
* This module is the pure half — argument extraction, text, reasons. It performs
|
||||
* no I/O and knows nothing about Cordis, so every rule below is testable without
|
||||
* a harness.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Model-supplied arguments that name a location, per tool.
|
||||
*
|
||||
* Deliberately conservative: only tools whose path argument is a plain top-level
|
||||
* string are listed. A tool that takes a path in a shape this table cannot read
|
||||
* is not guessed at — it is left unguarded, which is a decision, not an
|
||||
* oversight. `bash` is the deliberate one: a shell command cannot be judged
|
||||
* reliably from argv, so the facet is enforced on the file tools and `bash`
|
||||
* remains a legitimate way out. The prompt says so rather than pretending
|
||||
* otherwise.
|
||||
*/
|
||||
export const PATH_ARGUMENTS = Object.freeze({
|
||||
read: ['file_path'],
|
||||
write: ['file_path'],
|
||||
edit: ['file_path'],
|
||||
read_image: ['file_path'],
|
||||
grep: ['path'],
|
||||
glob: ['path'],
|
||||
str_replace_editor: ['path'],
|
||||
recall: ['path', 'focus'],
|
||||
})
|
||||
|
||||
/** The tool this plugin registers, and the action on it that widens the scope. */
|
||||
export const FACET_TOOL = 'facet'
|
||||
export const WIDENING_ACTION = 'add'
|
||||
|
||||
/**
|
||||
* Every path a tool call names, as the model wrote them.
|
||||
*
|
||||
* Tolerant by design: arguments arrive as unvalidated JSON, and a policy plugin
|
||||
* that throws on a malformed call would break tools it has no business breaking.
|
||||
* Anything that is not a non-empty string is skipped.
|
||||
*
|
||||
* An empty result means "no opinion" — either the tool names no path (so it
|
||||
* cannot leave the facet) or it names one in a shape we refuse to guess at.
|
||||
*
|
||||
* @param {string} toolName - the tool being called.
|
||||
* @param {unknown} args - the raw model arguments.
|
||||
* @returns {string[]} raw paths, in argument order.
|
||||
*/
|
||||
export function pathArgumentsFor(toolName, args) {
|
||||
if (args === null || typeof args !== 'object') return []
|
||||
const record = /** @type {Record<string, unknown>} */ (args)
|
||||
|
||||
const names = PATH_ARGUMENTS[toolName]
|
||||
if (names === undefined) return []
|
||||
|
||||
const out = []
|
||||
for (const name of names) {
|
||||
const value = record[name]
|
||||
if (typeof value !== 'string') continue
|
||||
const trimmed = value.trim()
|
||||
if (trimmed !== '') out.push(trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this call would widen the facet and therefore needs the user.
|
||||
*
|
||||
* Only `add` widens. `status` reads, `remove` narrows, `reset` returns to the
|
||||
* session's own folder — none of them can show the agent anything it could not
|
||||
* already see, so none of them is worth interrupting anyone for. Keeping the
|
||||
* interruption to the single case that matters is what stops the approval from
|
||||
* becoming something to click through.
|
||||
*
|
||||
* @param {string} toolName - the tool being called.
|
||||
* @param {unknown} args - the raw model arguments.
|
||||
* @returns {boolean} whether the call needs an explicit grant.
|
||||
*/
|
||||
export function isWideningCall(toolName, args) {
|
||||
if (toolName !== FACET_TOOL) return false
|
||||
if (args === null || typeof args !== 'object') return false
|
||||
return /** @type {Record<string, unknown>} */ (args).action === WIDENING_ACTION
|
||||
}
|
||||
|
||||
/**
|
||||
* The stable half of the facet, for the system prompt.
|
||||
*
|
||||
* This states the rule and never the binding: the binding moves (a grant is
|
||||
* added), and a changing system prompt invalidates the prompt cache for the
|
||||
* whole conversation. The mutable half goes in {@link facetContextText}, which
|
||||
* the harness materialises as a durable runtime-context snapshot — the same
|
||||
* treatment the sandbox policy and approval policy already get, for the same
|
||||
* reason.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled.
|
||||
* @returns {string} prompt section text, or `''` to contribute nothing.
|
||||
*/
|
||||
export function facetRuleText(input) {
|
||||
const { mode } = input
|
||||
if (mode === 'off') return ''
|
||||
|
||||
const reach =
|
||||
mode === 'deny'
|
||||
? 'A call outside it is refused. `facet action=add` asks the user to grant a path, and is the only way to widen the scope.'
|
||||
: 'A call outside it is not refused: it is put to the user as a question, so say in one line what you need and why. If they agree, record it with `facet action=add` so the rest of the session does not have to ask again.'
|
||||
|
||||
return [
|
||||
'# Facet scope',
|
||||
'',
|
||||
'This session is scoped to one facet of the project — one module rather than the whole system. Your runtime context states which.',
|
||||
'',
|
||||
`Work inside the facet. ${reach}`,
|
||||
'',
|
||||
'Never widen the scope on your own initiative: a grant is the user\u2019s decision, and an unrequested one is a bug, not initiative. Do not add paths you merely expect to need — ask when you actually need them.',
|
||||
'',
|
||||
'`bash` is not scope-checked, so it is not a way to look outside the facet without asking. Use the file tools when the answer is a file.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a path into the form worth showing a reader.
|
||||
*
|
||||
* Relative reads better when the path is under the session, and absolute is the
|
||||
* only honest form when it is not. This is presentation only — containment is
|
||||
* decided on canonical targets, never on strings.
|
||||
*
|
||||
* @param {string} cwd - the session working directory.
|
||||
* @param {string} path - an absolute path.
|
||||
* @returns {string} the shortest unambiguous form.
|
||||
*/
|
||||
export function displayPath(cwd, path) {
|
||||
if (typeof cwd !== 'string' || cwd === '') return path
|
||||
const prefix = cwd.endsWith('/') ? cwd : `${cwd}/`
|
||||
return path === cwd ? '.' : path.startsWith(prefix) ? path.slice(prefix.length) : path
|
||||
}
|
||||
|
||||
/**
|
||||
* The mutable half of the facet, for the runtime-context snapshot.
|
||||
*
|
||||
* Kept to three short lines because it is re-sent as part of the conversation
|
||||
* whenever it changes, and a scope statement that costs more than the scope is
|
||||
* worth is a scope that does not pay for itself.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string} input.cwd - the session working directory.
|
||||
* @param {string|undefined} input.root - the facet root, or undefined when unscoped.
|
||||
* @param {readonly string[]} input.extras - absolute granted paths, outside the root.
|
||||
* @param {'ask'|'deny'|'off'} input.mode - how an out-of-scope call is handled.
|
||||
* @returns {string} context text, or `''` to contribute nothing.
|
||||
*/
|
||||
export function facetContextText(input) {
|
||||
const { cwd, root, extras = [], mode } = input
|
||||
if (mode === 'off' || typeof root !== 'string' || root === '') return ''
|
||||
|
||||
// The root is stated absolutely even though it is the session's own directory
|
||||
// and a relative form would be shorter. `facet: .` is true and useless: the
|
||||
// model needs a path it can reason about, and the user reading an approval
|
||||
// prompt needs to see which folder they are being asked about.
|
||||
const lines = [`facet: ${root}`]
|
||||
if (extras.length > 0) lines.push(`granted outside it: ${extras.map((path) => displayPath(cwd, path)).join(', ')}`)
|
||||
lines.push(`outside the facet: ${mode === 'deny' ? 'refused' : 'ask the user'}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Why one call is being questioned, in a sentence that reads for both audiences.
|
||||
*
|
||||
* The same string is the user's approval prompt and the model's error text, so
|
||||
* it names the path, the boundary, and the way forward — a denial that does not
|
||||
* say how to proceed is a denial the agent works around with `bash`.
|
||||
*
|
||||
* @param {object} input
|
||||
* @param {string} input.cwd - the session working directory.
|
||||
* @param {string} input.path - the raw path the model named.
|
||||
* @param {string} input.root - the facet root.
|
||||
* @param {'ask'|'deny'} input.mode - the decision this reason explains.
|
||||
* @returns {string} the reason.
|
||||
*/
|
||||
export function outsideReason(input) {
|
||||
const { cwd, path, root, mode } = input
|
||||
const what = displayPath(cwd, path)
|
||||
const verb = mode === 'deny' ? 'refused' : 'outside'
|
||||
// The boundary is named absolutely: the person reading this is deciding
|
||||
// whether to open it, and "the facet is ." tells them nothing.
|
||||
return `${what} is ${verb} — this session's facet is ${root}. Ask the user to grant it (\`facet action=add\`), or work within the facet.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Why widening the scope is being put to the user.
|
||||
*
|
||||
* @param {readonly string[]} paths - the raw paths the model asked to add.
|
||||
* @returns {string} the reason.
|
||||
*/
|
||||
export function widenReason(paths) {
|
||||
const list = paths.length === 0 ? 'nothing' : paths.join(', ')
|
||||
return `Grant ${list} to this session — outside its facet — for the rest of the conversation?`
|
||||
}
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Public surface of the `dsh-facet/guard` row — the half mounted inside the
|
||||
* `facet` agent preset.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type z from '@deepseek-ai/schemastery'
|
||||
|
||||
/** Plugin name. */
|
||||
export declare const name = 'facet-guard'
|
||||
|
||||
/** Services the guard cannot work without; it stays pending until all are present. */
|
||||
export declare const inject: readonly ['facetPolicy', 'tools', 'systemPrompt', 'fs']
|
||||
|
||||
/** Configuration for the preset-scoped half. */
|
||||
export interface Config {
|
||||
/** Register the `facet` tool. Defaults to `true`; the guard works without it. */
|
||||
tool?: boolean
|
||||
}
|
||||
|
||||
export declare const Config: z<Config>
|
||||
|
||||
export declare function apply(ctx: Context, config: Config): void
|
||||
Vendored
+75
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Public surface of the `dsh-facet` host row.
|
||||
*
|
||||
* The augmentation below is the point of this file: `ctx.facetPolicy` is a
|
||||
* service published by `provide`, so without it a TypeScript consumer sees the
|
||||
* property as missing. The preset half (`dsh-facet/guard`) is typed in
|
||||
* `./guard.d.ts`.
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type z from '@deepseek-ai/schemastery'
|
||||
|
||||
/** One session's resolved scope. */
|
||||
export interface FacetScope {
|
||||
/** The session working directory, absolute — also the boundary. */
|
||||
readonly cwd: string
|
||||
/** The facet root; the session's own folder. */
|
||||
readonly root: string
|
||||
/** Absolute granted paths outside the root: standing grants, then this session's. */
|
||||
readonly extras: string[]
|
||||
}
|
||||
|
||||
/** How an out-of-scope call is handled. */
|
||||
export type FacetMode = 'ask' | 'deny' | 'off'
|
||||
|
||||
/** The deployment's facet policy, published by the host row. */
|
||||
export interface FacetPolicy {
|
||||
readonly mode: FacetMode
|
||||
/** System-prompt placement of the invariant rule. */
|
||||
readonly sectionOrder: number
|
||||
/** Runtime-context placement of the per-session binding. */
|
||||
readonly contextOrder: number
|
||||
/** Every path always in scope, as configured. */
|
||||
readonly standing: string[]
|
||||
/** Session ids currently holding a grant. */
|
||||
readonly sessions: string[]
|
||||
/** The scope of one agent, or `undefined` when it has no working directory. */
|
||||
scopeFor(agent: { session?: { id?: unknown; header?: { cwd?: unknown } } } | undefined): FacetScope | undefined
|
||||
/** Add paths to one session's grants. */
|
||||
grant(sessionId: string, paths: readonly string[], cwd: string): string[]
|
||||
/** Drop paths from one session's grants. */
|
||||
revoke(sessionId: string, paths: readonly string[], cwd: string): string[]
|
||||
/** Clear one session's grants. */
|
||||
reset(sessionId: string): string[]
|
||||
/** One session's own grants, without the standing ones. */
|
||||
grantsFor(sessionId: string): string[]
|
||||
/** Drop a finished session's grants. */
|
||||
forget(sessionId: string): void
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Published by `dsh-facet`; consumed by `dsh-facet/guard`. */
|
||||
facetPolicy: FacetPolicy
|
||||
}
|
||||
}
|
||||
|
||||
/** Plugin name. */
|
||||
export declare const name = 'dsh-facet'
|
||||
|
||||
/** Configuration: the deployment's facet policy. */
|
||||
export interface Config {
|
||||
/** How an out-of-scope call is handled. Defaults to `ask`. */
|
||||
mode?: FacetMode
|
||||
/** Paths always in scope; relative paths resolve against each session's directory. */
|
||||
extraRoots?: string[]
|
||||
/** Placement of the invariant rule in the system prompt. Defaults to `100`. */
|
||||
sectionOrder?: number
|
||||
/** Placement of the per-session binding in the runtime-context snapshot. Defaults to `105`. */
|
||||
contextOrder?: number
|
||||
}
|
||||
|
||||
export declare const Config: z<Config>
|
||||
|
||||
export declare function apply(ctx: Context, config: Config): void
|
||||
Reference in new issue
Block a user