diff --git a/index.js b/index.js index 35b3459..1899250 100644 --- a/index.js +++ b/index.js @@ -33,20 +33,25 @@ export const name = 'dsh-facet' /** * Every configuration field this plugin exposes. * - * `mode` and `extraRoots` are marked `volatile()`, and that marker is doing two - * jobs rather than one. It is what puts them on the settings page at all — the - * framework's form projection keeps only fields under a volatile ancestor, and - * drops an entry with no such field from `settings.describe` entirely, so - * without it this row has no card. It is also what makes an edit *live*: a - * volatile field is handed to {@link apply} as a reference rather than a value, - * and the framework commits an edit into that reference without re-applying the - * plugin. + * `mode`, `extraRoots` and `scopes` are marked `volatile()`, and that marker is + * doing two jobs rather than one. It is what puts them on the settings page at + * all — the framework's form projection keeps only fields under a volatile + * ancestor, and drops an entry with no such field from `settings.describe` + * entirely, so without it this row has no card. It is also what makes an edit + * *live*: a volatile field is handed to {@link apply} as a reference rather than + * a value, and the framework commits an edit into that reference without + * re-applying the plugin. * - * The second job is why every reader of these two fields goes through + * The second job is why every reader of these fields goes through * `configValue()` in `./lib/policy.js` and reads at the point of use. A plugin * that takes the reference for a string gets a settings card that appears to * work and changes nothing. * + * `scopes` is volatile for a third reason: it is the only field the browser + * context panel writes, and `settings.mutate` refuses a path that is not under a + * volatile node. That is the whole mechanism by which the panel — and not the + * agent — decides what one conversation may see. + * * `sectionOrder` and `contextOrder` are deliberately NOT volatile. They place * the rule and the binding among the other prompt sections, which is settled * once at load; making them volatile would put two knobs nobody should touch on @@ -61,12 +66,46 @@ export const Config = Schema.object({ mode: Schema.union(['ask', 'deny', 'off']).default('ask').volatile(), /** - * Paths always in scope, for code a module legitimately depends on — shared - * types, a common test harness. Relative paths resolve against each session's - * own directory, so the same list is meaningful in every workspace. + * A deployment-wide seed of paths always in scope, for code a module + * legitimately depends on — shared types, a common test harness. + * + * Deliberately only a seed. Before per-session scope existed this was the only + * way to widen anything, which meant "this one conversation needs the entity + * folder" had to be written as a fact about every conversation in every + * workspace. Per-session scope belongs in {@link Config.scopes}; this is for + * the paths a deployment genuinely always wants, and it is empty by default. + * + * Relative paths resolve against each session's own directory, so the same + * list is meaningful in every workspace. */ extraRoots: Schema.array(String).default([]).volatile(), + /** + * Per-session scope — the user's declaration for one conversation, keyed by + * session id. + * + * Written only by the browser context panel, through the settings service, and + * never by the agent: this plugin exposes no write path to it, and a facet + * session carries no settings tool. `focus` narrows the session to a folder; + * `extras` are paths the conversation may read outside it. Both are relative + * to the session's own directory unless absolute, so a declaration keeps its + * meaning wherever the workspace lives. + * + * Entries outlive their sessions, which is deliberate — reopening a chat + * should give back the scope it had — and costs a little growth in the + * profile patch that the panel can tidy. + */ + scopes: Schema.dict( + Schema.object({ + /** The session's facet root, relative to the session directory or absolute. Empty means the session directory itself. */ + focus: Schema.string(), + /** Paths this session may also read, outside the root. */ + extras: Schema.array(String).default([]), + }), + ) + .default({}) + .volatile(), + /** Placement of the invariant rule in the system prompt. */ sectionOrder: Schema.number().default(100), diff --git a/lib/guard.js b/lib/guard.js index cd34ae5..341b8f8 100644 --- a/lib/guard.js +++ b/lib/guard.js @@ -246,13 +246,18 @@ function requestedPaths(args) { } /** - * The `facet` tool: read the scope, or ask to widen it. + * The `facet` tool: read the scope, narrow it, 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`. * + * `focus` is the one action this tool performs without asking, and it is the + * only one that can only ever *reduce* the scope. That asymmetry is the whole + * design: an agent may decide a conversation is about one folder, and may not + * decide it is about more. + * * @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context. * @param {object} policy - the `facetPolicy` service. */ @@ -260,21 +265,23 @@ function facetTool(ctx, policy) { 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.', + 'Read this session\u2019s facet scope, narrow it to one folder, or ask the user for an extra path. A facet is ' + + 'the folder the conversation is scoped to; `status` reports it. Use `focus` when the work has settled on one ' + + 'folder inside the facet — narrowing is always allowed and takes effect at once. 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. Where the deployment has no approval ' + + 'channel, `add` cannot be granted at all and the user widens the scope from the facet panel in their browser.', parameters: { action: { type: 'string', required: true, - description: 'One of: status, add, remove, reset.', + description: 'One of: status, focus, 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.', + 'Paths, relative to the session directory or absolute. Required for add and remove; for focus, exactly one folder inside the current facet.', }, }, output: { @@ -307,13 +314,16 @@ function facetTool(ctx, policy) { 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 + : action === 'focus' && requested.length !== 1 + ? 'facet: action "focus" needs exactly one folder' + : !['status', 'focus', 'add', 'remove', 'reset'].includes(action) + ? `facet: unknown action "${action}"; expected status, focus, 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) + if (action === 'focus') policy.narrowTo(sessionId, await narrowing(ctx, policy, exec, requested[0])) + else if (action === 'add') policy.grant(sessionId, requested, cwd) else if (action === 'remove') policy.revoke(sessionId, requested, cwd) else if (action === 'reset') policy.reset(sessionId) @@ -340,6 +350,48 @@ function facetTool(ctx, policy) { }) } +/** + * Resolve one `focus` request to the canonical root it asks for. + * + * Refuses anything the scope already in force does not contain, because that + * would be a widening by another name — and widening is the user's decision, not + * the agent's. The containment test is the filesystem provider's, on canonical + * targets, for the same reason the guard uses it: a symlink inside the facet + * that points outside it must not become a way through. + * + * The canonical key is what gets recorded, so a later `scopeFor` compares like + * with like. Where the user declared a root through a symlink, the two spellings + * may not share a prefix; the narrowing is then dropped rather than trusted, + * which loses the narrowing and never the boundary. + * + * @param {import('@deepseek-ai/cordis').Context} ctx - the guard's context, holding `fs`. + * @param {object} policy - the `facetPolicy` service. + * @param {import('@deepseek-ai/dsh-tools').ToolExecution} exec - the running call. + * @param {string} raw - the folder the model named. + * @returns {Promise} the absolute root to narrow to. + */ +async function narrowing(ctx, policy, exec, raw) { + const scope = policy.scopeFor(exec.agent) + if (scope === undefined) throw new Error('facet: this session has no working directory, so there is nothing to narrow') + + let target + let boundary + try { + target = await ctx.fs.resolve(raw, { cwd: scope.cwd, signal: exec.signal }) + boundary = await ctx.fs.resolve(scope.root, { cwd: scope.cwd, signal: exec.signal }) + } catch { + throw new Error(`facet: cannot resolve "${raw}", so the facet cannot be narrowed to it`) + } + + if (target.targetKey !== boundary.targetKey && !ctx.fs.contains(boundary, target)) { + throw new Error( + `facet: "${raw}" is outside the current facet (${scope.root}). Narrowing cannot leave the scope already in force; ` + + 'ask the user to widen it from the facet panel if the work really has moved.', + ) + } + return target.targetKey +} + /** Model-facing text for one `facet` result. Pure. */ function renderFacet(value) { const lines = [ diff --git a/lib/policy.js b/lib/policy.js index 8cd71b0..fcd84f3 100644 --- a/lib/policy.js +++ b/lib/policy.js @@ -16,7 +16,7 @@ * exists to prevent. */ -import { isAbsolute, resolve } from 'node:path' +import { isAbsolute, resolve, sep } from 'node:path' /** * The symbol cosmokit uses to mark a volatile config reference. @@ -63,18 +63,78 @@ export function configValue(value) { * `config` is the *resolved* composition config, so its volatile fields are * references rather than values; every read below goes through * {@link configValue} and happens per use. Holding the config and reading it lazily - * is what makes the settings card honest: `mode` and `extraRoots` are edited live - * in the Web UI, and a policy that snapshotted them here would keep answering - * with the values it was loaded with. + * is what makes the settings card honest: `mode`, `extraRoots` and `scopes` are + * edited live in the Web UI, and a policy that snapshotted them here would keep + * answering with the values it was loaded with. + * + * ## Two layers of scope, and only one of them the agent can move + * + * The session's scope is declared in two places on purpose, because the two + * audiences have opposite authority: + * + * * 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. The agent has no write path to it: nothing in this plugin exposes + * one, and a facet session carries no settings tool. + * * the **agent layer** is in memory — a narrowing `focus` override and the + * grants it asked for. Both can only ever *reduce* what the user layer + * allows, so an agent that goes wrong cannot widen its own scope. + * + * The effective root is the agent's narrowing while it still sits inside the + * user's root, and the user's root otherwise. That ordering matters when the + * user narrows after the agent has: the stale agent focus is dropped rather than + * honoured, because honouring it would put the session outside the scope the + * user had just declared. * * @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. + * @param {readonly string[]} [config.extraRoots] - a deployment-wide seed of standing grants. + * @param {Record} [config.scopes] - per-session scope, keyed by session id. * @returns {object} the `facetPolicy` service implementation. */ export function createFacetPolicy(config) { /** @type {Map} session id → granted paths, in the order granted. */ const grants = new Map() + /** @type {Map} session id → the agent's narrowing focus, absolute. */ + const narrowed = new Map() + + /** + * What the user declared for one session, read fresh. + * + * @param {string|undefined} sessionId - the session to read. + * @returns {{focus?: string, extras?: readonly string[]}|undefined} the declaration. + */ + function declaredScope(sessionId) { + if (sessionId === undefined) return undefined + const all = configValue(config.scopes) + if (all === null || typeof all !== 'object') return undefined + const entry = all[sessionId] + return entry !== null && typeof entry === 'object' ? entry : undefined + } + + /** + * The root in force for one session. + * + * The user's `focus` is resolved against the session directory, not against + * the process, so a relative declaration keeps its meaning if the workspace + * moves. The agent's narrowing is applied only while it is still inside that + * root; a plain prefix test is enough here because the guard's own containment + * check, which resolves symlinks, is what authorises a narrowing in the first + * place — this one only has to notice a root that has since moved out from + * under it. + * + * @param {string|undefined} sessionId - the session. + * @param {string} sessionCwd - its resolved working directory. + * @returns {string} the absolute facet root. + */ + function rootFor(sessionId, sessionCwd) { + const declared = declaredScope(sessionId)?.focus + const base = typeof declared === 'string' && declared.trim() !== '' ? resolve(sessionCwd, declared) : sessionCwd + const agentFocus = sessionId === undefined ? undefined : narrowed.get(sessionId) + if (agentFocus === undefined) return base + if (agentFocus === base || agentFocus.startsWith(base.endsWith(sep) ? base : `${base}${sep}`)) return agentFocus + return base + } /** * The deployment's standing grants, read fresh on every call. @@ -97,31 +157,77 @@ export function createFacetPolicy(config) { * 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". * + * `cwd` stays the session directory even when the root is narrower, because + * it is what relative paths — a grant, a `focus`, an `extraRoots` entry — + * resolve against. A declaration written relative to the session keeps the + * same meaning whatever the session is focused on. + * * @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) } + const sessionCwd = resolve(cwd) + const sessionId = idOf(agent) + return { + cwd: sessionCwd, + root: rootFor(sessionId, sessionCwd), + extras: extrasFor(agent, sessionCwd), + } } /** - * Every path in scope beyond the root: standing grants first, then this - * session's, deduplicated so the prompt does not repeat itself. + * Every path in scope beyond the root: the deployment seed, then what the user + * declared for this session, then what the agent was granted — deduplicated so + * the prompt does not repeat itself. + * + * All three are kept rather than layered, because they answer different + * questions. The seed is "this deployment always needs it", the declaration is + * "this conversation needs it", and a grant is "the user allowed this call". + * Removing one from the list when a narrower one exists would mean a user + * editing their panel could silently drop a path the deployment depends on. * * @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 sessionId = idOf(agent) const own = sessionId === undefined ? [] : grants.get(sessionId) ?? [] - const all = [...standingRoots(), ...own].map((path) => (isAbsolute(path) ? resolve(path) : resolve(cwd, path))) + const declared = declaredScope(sessionId)?.extras + const listed = Array.isArray(declared) ? declared.filter(isPath) : [] + const all = [...standingRoots(), ...listed, ...own].map((path) => + isAbsolute(path) ? resolve(path) : resolve(cwd, path), + ) return [...new Set(all)] } + /** + * Narrow one session's facet to a path the caller has already authorised. + * + * The containment check is the caller's, and deliberately so: only the guard + * holds `ctx.fs`, and deciding containment on canonical targets is the whole + * reason a symlink inside the facet does not open it. This records the answer; + * {@link rootFor} re-checks that the user has not since narrowed past it. + * + * @param {string} sessionId - the session being narrowed. + * @param {string} absolute - the absolute root to narrow to. + * @returns {string} the recorded focus. + */ + function narrowTo(sessionId, absolute) { + narrowed.set(sessionId, absolute) + return absolute + } + + /** + * @param {string} sessionId - the session to read. + * @returns {string|undefined} the agent's narrowing for it, if any. + */ + function narrowFor(sessionId) { + return narrowed.get(sessionId) + } + /** * @param {string} sessionId - the session being widened. * @param {readonly string[]} paths - raw paths, resolved against `cwd`. @@ -154,11 +260,19 @@ export function createFacetPolicy(config) { } /** + * Clear one session's agent-side scope: its grants and any narrowing it asked + * for. + * + * Deliberately not a widening. The user's declaration is not touched, so this + * returns the session to what the user set up in the panel — which may be + * narrower than where the agent had got to, and can never be wider. + * * @param {string} sessionId - the session to clear. * @returns {string[]} always empty. */ function reset(sessionId) { grants.delete(sessionId) + narrowed.delete(sessionId) return [] } @@ -173,6 +287,7 @@ export function createFacetPolicy(config) { /** Drop a finished session's grants. Called on `session/disposed`. */ function forget(sessionId) { grants.delete(sessionId) + narrowed.delete(sessionId) } return { @@ -183,11 +298,16 @@ export function createFacetPolicy(config) { /** Prompt placements, carried here so the preset row needs no configuration of its own. */ sectionOrder: configValue(config.sectionOrder) ?? 100, contextOrder: configValue(config.contextOrder) ?? 105, - /** The standing grants. A getter for the same reason as {@link mode}. */ + /** The deployment-wide seed of standing grants. A getter for the same reason as {@link mode}. */ get standing() { return standingRoots() }, + /** What the user declared for one session: their `focus` and `extras`. */ + declaredFor: declaredScope, scopeFor, + rootFor, + narrowTo, + narrowFor, grant, revoke, reset, @@ -204,3 +324,13 @@ export function createFacetPolicy(config) { function isPath(value) { return typeof value === 'string' && value.trim() !== '' } + +/** + * The session id of a calling agent, when it has one. + * + * @param {{session?: {id?: unknown}}|undefined} agent - the calling agent. + * @returns {string|undefined} the id. + */ +function idOf(agent) { + return typeof agent?.session?.id === 'string' ? agent.session.id : undefined +} diff --git a/lib/scope.js b/lib/scope.js index dd60943..072f9f8 100644 --- a/lib/scope.js +++ b/lib/scope.js @@ -195,12 +195,12 @@ export function facetContextText(input) { // 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. + // works: the facet's context panel, which the user owns and the agent has no + // write path to. 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`' + ? '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 — `facet action=add` asks the user to grant a path, and is the only way to widen the scope' + ? '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 diff --git a/test/client.test.js b/test/client.test.js index 0e7cf4d..f26b13d 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -160,14 +160,22 @@ test('nothing is registered while the Host does not serve the namespace', async assert.equal(seen.entry, undefined, 'but no card is registered for a namespace nobody serves') }) -test('the card stages exactly the fields the host marks volatile', async () => { +test('the card stages exactly the deployment fields the host marks volatile', async () => { const { module: mod, staged } = await loadClient() drive(mod) - const declared = Object.entries(Config.dict) + const volatileFields = Object.entries(Config.dict) .filter(([, schema]) => schema.meta.volatile === true) .map(([key]) => key) + // `scopes` is volatile too, but it is not a deployment field and must not be a + // control on the card: it is per-session state, written by the context panel + // and keyed by session id. Staging it here would render a dictionary blob a + // user could not sensibly edit, and would put the panel's store on the one + // surface that edits the deployment. + assert.ok(volatileFields.includes('scopes'), 'scopes must be volatile or the panel cannot write it') + const declared = volatileFields.filter((key) => key !== 'scopes') + // A field served but never staged is invisible in the UI; a field staged but // never served makes `whileServed` withhold the whole card. assert.deepEqual( diff --git a/test/guard.test.js b/test/guard.test.js index 65cd711..8729710 100644 --- a/test/guard.test.js +++ b/test/guard.test.js @@ -107,6 +107,7 @@ before(async () => { root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-facet-'))) facet = join(root, 'src', 'controllers', 'room') await mkdir(facet, { recursive: true }) + await mkdir(join(facet, 'inner'), { recursive: true }) await mkdir(join(root, 'src', 'controllers', 'other'), { recursive: true }) await mkdir(join(root, 'src', 'shared'), { recursive: true }) await writeFile(join(facet, 'index.ts'), 'export {}\n') @@ -293,9 +294,10 @@ test('the binding refuses honestly when the session has no approval channel', () const { captured } = harness({ mode: 'ask', approval: 'never' }) const text = captured.context.text({ agent: agent() }) - assert.match(text, /approval prompts are off in this session/) - assert.match(text, /cannot be approved either/) - assert.match(text, /`extraRoots`/) + assert.match(text, /no approval channel/) + assert.match(text, /cannot be granted/) + // It names the mechanism that still works, which is the panel the user owns. + assert.match(text, /context panel/) // The promise `ask` makes cannot be kept here, so it must not be made. assert.doesNotMatch(text, /put to the user as a question/) @@ -308,14 +310,14 @@ test('an approval channel keeps the promise, and a missing service counts as non // No approval service at all: the tool pipeline denies an ask it cannot route, // so the binding must not offer one. const without = harness({ mode: 'ask' }) - assert.match(without.captured.context.text({ agent: agent() }), /approval prompts are off in this session/) + assert.match(without.captured.context.text({ agent: agent() }), /no approval channel/) }) test('deny mode still names the way that works when there is no channel', () => { const { captured } = harness({ mode: 'deny', approval: 'never' }) const text = captured.context.text({ agent: agent() }) assert.match(text, /refused/) - assert.match(text, /`extraRoots`/) + assert.match(text, /context panel/) }) test('the facet tool reports the scope', async () => { @@ -347,6 +349,60 @@ test('the facet tool grants, narrows and clears', async () => { assert.deepEqual(policy.grantsFor('session-1'), []) }) +test('the facet tool narrows inside the facet and refuses a folder outside it', async () => { + const { captured, policy } = harness() + const exec = { agent: agent() } + + // Narrowing is the one scope change the agent may make unasked: it can only + // ever reduce what is visible, so there is nothing for a user to approve. + const narrowed = await captured.tool.execute({ action: 'focus', paths: ['inner'] }, exec) + assert.equal(narrowed.root, join(facet, 'inner')) + assert.equal(narrowed.facet, join(facet, 'inner')) + assert.equal(policy.scopeFor(agent()).root, join(facet, 'inner')) + + // A folder outside the scope already in force is a widening by another name, + // and widening is the user's decision, taken in their panel. + await assert.rejects( + () => captured.tool.execute({ action: 'focus', paths: ['../../shared'] }, exec), + /outside the current facet/, + ) + await assert.rejects( + () => captured.tool.execute({ action: 'focus', paths: ['../other'] }, exec), + /outside the current facet/, + ) + // A refused narrowing must not have moved anything. + assert.equal(policy.scopeFor(agent()).root, join(facet, 'inner')) +}) + +test('the facet tool needs exactly one folder to focus on', async () => { + const { captured } = harness() + const exec = { agent: agent() } + await assert.rejects(() => captured.tool.execute({ action: 'focus' }, exec), /exactly one folder/) + await assert.rejects( + () => captured.tool.execute({ action: 'focus', paths: ['inner', '../other'] }, exec), + /exactly one folder/, + ) +}) + +test('reset lands on the scope the user declared, never back on the session directory', async () => { + const { captured, policy } = harness({ + config: { mode: 'deny', scopes: { 'session-1': { focus: 'src/controllers/room' } } }, + }) + const exec = { agent: agent(root) } + + // The user declared this conversation to be about the room controller, so the + // session is already narrower than the directory it was started in. + assert.equal(policy.scopeFor(agent(root)).root, facet) + + await captured.tool.execute({ action: 'focus', paths: ['src/controllers/room/inner'] }, exec) + assert.equal(policy.scopeFor(agent(root)).root, join(facet, 'inner')) + + // Clearing the agent's side has to land on the user's declaration. Landing on + // the session directory would make `reset` a widening. + await captured.tool.execute({ action: 'reset' }, exec) + assert.equal(policy.scopeFor(agent(root)).root, facet) +}) + test('the facet tool refuses a call it cannot act on', async () => { const { captured } = harness() const exec = { agent: agent() } diff --git a/test/policy.test.js b/test/policy.test.js index 11913fe..72281c4 100644 --- a/test/policy.test.js +++ b/test/policy.test.js @@ -93,3 +93,97 @@ test('prompt placements are carried, so the preset row needs no config', () => { assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).sectionOrder, 7) assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).contextOrder, 8) }) + +// ── per-session scope ──────────────────────────────────────────────────────── +// +// The declaration is the user's, written from the browser panel; the narrowing +// is the agent's, held in memory. Everything below is about which of the two +// wins, and the answer is always "the narrower one, and never a stale agent +// focus that the user has since narrowed past". + +test('a declared focus narrows the root, and the session directory stays the base', () => { + const policy = createFacetPolicy({ + mode: 'deny', + scopes: { s1: { focus: 'src/controllers/room' } }, + }) + const scope = policy.scopeFor(agentAt('/w', 's1')) + + assert.equal(scope.root, '/w/src/controllers/room') + // `cwd` is still where relative paths resolve from — a declaration written + // against the session keeps its meaning whatever the session focuses on. + assert.equal(scope.cwd, '/w') +}) + +test('a declaration is read live, so the panel needs no remount', () => { + const scopes = {} + const policy = createFacetPolicy({ mode: 'deny', scopes }) + assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w') + + // What a panel write does to the running reference. + scopes.s1 = { focus: 'src/module' } + assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/module') +}) + +test('declared extras join the deployment seed and the session grants', () => { + const policy = createFacetPolicy({ + mode: 'deny', + extraRoots: ['seed'], + scopes: { s1: { focus: 'src/room', extras: ['shared', '/abs/common'] } }, + }) + policy.grant('s1', ['granted'], '/w') + + assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, [ + '/w/seed', + '/w/shared', + '/abs/common', + '/w/granted', + ]) +}) + +test('an agent narrowing is honoured while it stays inside the declared root', () => { + const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src' } } }) + policy.narrowTo('s1', '/w/src/room') + + assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room') + assert.equal(policy.narrowFor('s1'), '/w/src/room') +}) + +test('a narrowing the user has since narrowed past is dropped, not honoured', () => { + const scopes = { s1: { focus: 'src' } } + const policy = createFacetPolicy({ mode: 'deny', scopes }) + policy.narrowTo('s1', '/w/src/room') + assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/room') + + // The user narrows to a sibling: the agent's focus is now outside the scope + // they declared. Honouring it would put the session back where the user had + // just decided it should not be. + scopes.s1 = { focus: 'src/other' } + assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src/other') +}) + +test('reset and forget drop the agent side and leave the declaration alone', () => { + const policy = createFacetPolicy({ mode: 'deny', scopes: { s1: { focus: 'src', extras: ['shared'] } } }) + policy.narrowTo('s1', '/w/src/room') + policy.grant('s1', ['granted'], '/w') + + policy.reset('s1') + // Back to what the user declared — narrower than the session, never wider. + assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src') + assert.deepEqual(policy.grantsFor('s1'), []) + assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/shared']) + + policy.narrowTo('s1', '/w/src/room') + policy.forget('s1') + assert.equal(policy.narrowFor('s1'), undefined) + assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w/src') +}) + +test('junk in a declaration is ignored rather than thrown on', () => { + const policy = createFacetPolicy({ + mode: 'deny', + scopes: { s1: { focus: ' ', extras: ['ok', '', 42, null] }, s2: 'not an object' }, + }) + assert.equal(policy.scopeFor(agentAt('/w', 's1')).root, '/w') + assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/ok']) + assert.equal(policy.scopeFor(agentAt('/w', 's2')).root, '/w') +}) diff --git a/test/scope.test.js b/test/scope.test.js index bddaca3..fd82b41 100644 --- a/test/scope.test.js +++ b/test/scope.test.js @@ -82,7 +82,7 @@ test('facetContextText carries the binding that does change', () => { deny, 'facet: /w/src/room\n' + 'granted outside it: src/shared\n' + - 'outside the facet: refused — `facet action=add` asks the user to grant a path, and is the only way to widen the scope', + 'outside the facet: refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted', ) // The binding is the only half that can follow a live edit, so the two modes @@ -97,14 +97,14 @@ test('facetContextText carries the binding that does change', () => { test('a session with no approval channel says so instead of promising a question', () => { // `approval: never` refuses every ask before an answerer is consulted, so the // mode stops mattering: `ask` and `deny` both end in a refusal, and - // `facet action=add` cannot be approved either. `canAsk: false` is the one + // `facet action=add` cannot be granted either. `canAsk: false` is the one // input that collapses them, and it has to name the mechanism that still - // works — `extraRoots` is configuration, not a per-conversation grant. + // works — the context panel, which the user owns. for (const mode of ['ask', 'deny']) { const text = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode, canAsk: false }) - assert.match(text, /outside the facet: refused — approval prompts are off in this session/) - assert.match(text, /`facet action=add` cannot be approved either/) - assert.match(text, /`extraRoots`/) + assert.match(text, /outside the facet: refused — this session has no approval channel/) + assert.match(text, /`facet action=add` cannot be granted/) + assert.match(text, /context panel/) assert.doesNotMatch(text, /put to the user as a question/) }