The facet root was the session's working directory and nothing else, so the only way to scope a conversation to a module was to make the module a workspace — and the only way to reach anything outside it was `extraRoots`, which is a fact about the deployment rather than about one conversation. Scope now has two layers, and the split is the point. The **user layer** is `config.scopes[sessionId]` — a `focus` and a list of `extras`, written only from the browser context panel through the settings service, which is what `settings.mutate` requiring a volatile path buys us. The **agent layer** is in memory: a narrowing `focus` and its grants. Both can only ever reduce what the user layer allows, so an agent that goes wrong cannot widen its own scope. `facet action=focus` is therefore the one scope change the agent may make unasked, bounded by the scope already in force and decided on canonical targets. `reset` clears the agent's side only, landing on the user's declaration rather than on the session directory — landing on the directory would have made `reset` a widening. `extraRoots` demotes to an optional deployment-wide seed. The binding now names the context panel as the way to widen, because it is: in a deployment with no approval channel, `facet action=add` cannot be granted at all.
249 lines
12 KiB
JavaScript
249 lines
12 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'],
|
|
})
|
|
|
|
/** 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.
|
|
*
|
|
* 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 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?`
|
|
}
|