Files
dsh-facet/lib/scope.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

297 lines
14 KiB
JavaScript

/**
* 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'],
})
/**
* Tools that will search the session directory when the model names no path.
*
* The guard's rule is that a call naming no path is not judged, because a tool
* that names no path cannot leave the facet. That holds for a tool with no path
* argument at all, and fails for these three: their *own* default is the session
* workspace. `grep` and `glob` document both their path arguments as "Defaults to
* the session workspace", and `recall` falls back to a directory its own marker
* search picks — an ancestor of the facet whenever the facet carries no
* `CONTROLLER.md` of its own.
*
* So 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` reads outside the facet. An absent argument is not
* an absent path; it is the default one, and that is the path the guard judges.
*
* `recall` is another package's tool and is named here because the coupling to
* its two path arguments already exists in {@link PATH_ARGUMENTS}: leaving it
* out would keep a read path the table claims to cover.
*/
export const IMPLICIT_SESSION_PATHS = Object.freeze(['grep', 'glob', 'recall'])
/** 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. The
* first half of that is not true of every tool: the ones whose own default is a
* directory are listed in {@link IMPLICIT_SESSION_PATHS}, and the guard judges
* them against the directory they will actually search.
*
* @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.
*
* It takes no configuration, and that is deliberate rather than an oversight.
* A system-prompt section is a plain string registered once; there is no
* callback to re-render it, and a settings write to a `volatile()` field
* updates the config reference *without* re-applying the plugin. So anything
* this function interpolated would freeze at whatever the value was at load —
* the prompt would go on describing the old `mode` while the guard enforced the
* new one. Everything that depends on `mode` therefore lives in the binding,
* which is a callback and therefore live.
*
* @returns {string} prompt section text.
*/
export function facetRuleText() {
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, and says how a call outside it is treated.',
'',
'Work inside the facet.',
'',
'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 short 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. It carries one sentence more than the bare
* binding used to, because `mode` now belongs here: this is the only half that
* is re-rendered per request, so it is the only half that can follow a settings
* change. See {@link facetRuleText}.
*
* @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.
* @param {boolean} [input.canAsk] - whether an approval request can reach the user. False means every ask is refused before anyone is consulted.
* @returns {string} context text, or `''` to contribute nothing.
*/
export function facetContextText(input) {
const { cwd, root, extras = [], mode, canAsk = true } = input
if (mode === 'off' || typeof root !== 'string' || root === '') return ''
// Stated in terms of what the model should *do*, because the same sentence is
// what the user reads in an approval prompt. A denial that does not say how to
// proceed is a denial the agent works around with `bash`.
//
// `canAsk` comes first because it collapses the other two. A session can be
// composed without an approval channel — the `danger-full-access` permission
// preset sets `approval: never`, and `decide()` then returns "rejected" before
// any answerer is consulted. There, an out-of-facet call is refused whatever
// `mode` says, and `facet action=add` is refused with it, because widening is
// itself a question. Telling the agent to ask would send it into a loop, and
// the reason this guard writes never reaches it: `serviceAsk` replaces a
// rejected ask with `the user rejected tool "…"`. So the binding is the one
// channel that can say what is true, and it names the mechanism that still
// works: the facet's context panel, which the user owns and the agent has no
// write path to.
const reach = !canAsk
? 'refused — this session has no approval channel, so `facet action=add` cannot be granted; ask the user to widen the facet from its context panel'
: mode === 'deny'
? 'refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted'
: 'put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again'
// 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: ${reach}`)
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 a search that named no directory is being questioned.
*
* A separate sentence from {@link outsideReason} because the fault is different.
* There, the model named a path outside the facet and is told which one. Here it
* named nothing, so the path at issue is one it never wrote: the tool's own
* default. Saying so is the whole remedy — the fix is a second argument, not a
* grant — so the reason names the directory the search would have covered and
* the one it had to stay in.
*
* @param {object} input
* @param {string} input.cwd - the session working directory the tool defaults to.
* @param {string} input.root - the facet root.
* @returns {string} the reason.
*/
export function implicitSearchReason(input) {
const { cwd, root } = input
return (
`This search names no \`path\`, so it would cover this session's directory ${cwd} — and this session's ` +
`facet is ${root}. Name a folder inside the facet, or ask the user to grant the one you need.`
)
}
/**
* 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?`
}