/** * 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} */ (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} */ (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 — `extraRoots` is configuration applied by the profile, not a grant // asked for per conversation. const reach = !canAsk ? 'refused — approval prompts are off in this session, so `facet action=add` cannot be approved either; only the user can widen this, by adding the path to `extraRoots`' : mode === 'deny' ? 'refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope' : '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?` }