/** * The guard, driven through the real `apply` against a fake context and a real * filesystem. * * The filesystem is real because the interesting failures are filesystem * failures: `/a/b` is not inside `/a/bc`, and a symlink inside the facet that * points outside it is the one case where deciding on strings rather than on * canonical targets would quietly open the scope. Both are tested against * `mkdtemp` and `realpath`, not against a mock's opinion of path semantics. */ import assert from 'node:assert/strict' import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises' import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { after, before, test } from 'node:test' import { apply as guard } from '../lib/guard.js' import { createFacetPolicy } from '../lib/policy.js' import { Config } from '../index.js' /** * The symbol cosmokit marks a volatile config reference with. A settings write * reaches a running plugin as `target[write](value)` — that is literally what * `updateVolatile` does — so a test can commit an edit the way the framework * does, without a settings service. */ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write') /** A filesystem provider with `dsh-fs-local`'s two relevant behaviours. */ const fsLikeLocal = { /** Canonical target, walking up past a leaf that does not exist yet. */ async resolve(path, opts = {}) { const displayPath = isAbsolute(path) ? resolve(path) : resolve(opts.cwd ?? process.cwd(), path) try { return { targetKey: await realpath(displayPath), displayPath } } catch (error) { if (error.code !== 'ENOENT') throw error } const missing = [basename(displayPath)] let ancestor = dirname(displayPath) for (;;) { try { return { targetKey: join(await realpath(ancestor), ...missing), displayPath } } catch (error) { if (error.code !== 'ENOENT') throw error } const parent = dirname(ancestor) if (parent === ancestor) throw new Error(`cannot resolve ${displayPath}`) missing.unshift(basename(ancestor)) ancestor = parent } }, contains(parent, child) { const path = relative(parent.targetKey, child.targetKey) return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)) }, } /** * Capture what `apply` registered, so the test can drive it. * * `approval` stands in for the approval service named by its policy: `'ask'` * and `'never'` are the two the schema admits, and leaving it undefined models a * deployment that composed no approval service at all — a case the tool pipeline * denies outright, so the guard must treat it as no channel. */ function harness({ mode = 'ask', extraRoots = [], tool = true, config, approval } = {}) { const policy = createFacetPolicy(config ?? { mode, extraRoots }) const captured = { section: undefined, context: undefined, tool: undefined, preExecute: undefined } const ctx = { facetPolicy: policy, fs: fsLikeLocal, get: (name) => name === 'approval' && approval !== undefined ? { effectivePolicy: () => approval } : undefined, systemPrompt: { section: (value) => { captured.section = value }, context: (value) => { captured.context = value }, }, tools: { register: (value) => { captured.tool = value }, }, on: (event, listener) => { if (event === 'tools/pre-execute') captured.preExecute = listener }, } guard(ctx, { tool }) return { policy, ctx, captured } } /** Dispatch one call the way the tool pipeline would: our listener wraps `next()`. */ const dispatch = (captured, { name, args, agent, inner = { kind: 'allow' } }) => captured.preExecute({ name, arguments: args, agent, signal: new AbortController().signal }, async () => inner) /** @type {string} */ let root /** @type {string} */ let facet const agent = (cwd = facet, id = 'session-1') => ({ session: { id, header: { cwd } } }) 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') await writeFile(join(root, 'src', 'shared', 'types.ts'), 'export type T = 1\n') // The case a string comparison would miss. await symlink(join(root, 'src', 'shared', 'types.ts'), join(facet, 'link.ts')) }) after(async () => { await rm(root, { recursive: true, force: true }) }) test('a path inside the facet is ordinary work', async () => { const { captured } = harness() assert.deepEqual(await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: agent() }), { kind: 'allow', }) assert.deepEqual( await dispatch(captured, { name: 'grep', args: { pattern: 'x', path: '.' }, agent: agent() }), { kind: 'allow' }, ) }) test('a path outside the facet is put to the user', async () => { const { captured } = harness() const decision = await dispatch(captured, { name: 'read', args: { file_path: '../../shared/types.ts' }, agent: agent(), }) assert.equal(decision.kind, 'ask') assert.match(decision.reason, /src\/shared\/types\.ts/) assert.match(decision.reason, /src\/controllers\/room/) }) test('the sibling module is outside, which is the entire point', async () => { const { captured } = harness() const decision = await dispatch(captured, { name: 'read', args: { file_path: '../other/index.ts' }, agent: agent(), }) assert.equal(decision.kind, 'ask') }) test('a symlink inside the facet does not open it', async () => { const { captured } = harness() const decision = await dispatch(captured, { name: 'read', args: { file_path: 'link.ts' }, agent: agent() }) assert.equal(decision.kind, 'ask', 'containment must be decided on canonical targets') }) test('a prefix is not a parent', async () => { const { captured } = harness() await mkdir(join(root, 'src', 'controllers', 'roommate'), { recursive: true }) const decision = await dispatch(captured, { name: 'read', args: { file_path: '../roommate/x.ts' }, agent: agent(), }) assert.equal(decision.kind, 'ask') }) test('an omitted path means the session directory, which here is the facet', async () => { const { captured } = harness() // Allowed because the default and the facet are the same folder — not because // the call named nothing. The next test is the case where they differ. assert.deepEqual(await dispatch(captured, { name: 'glob', args: { pattern: '**/*.ts' }, agent: agent() }), { kind: 'allow', }) }) test('a search that names no path is judged against the directory it would cover', async () => { // The session directory is the facet's parent here: the user narrowed this // conversation to `inner`, while the search tools still default to the whole // session directory. An absent argument is not an absent path — it is the // default one, and the default one leaves the facet. const { captured } = harness({ config: { mode: 'ask', scopes: { 'session-1': { focus: 'inner' } } } }) const decision = await dispatch(captured, { name: 'grep', args: { pattern: 'motion_learning' }, agent: agent() }) assert.equal(decision.kind, 'ask') assert.match(decision.reason, /names no `path`/) assert.match(decision.reason, /inner/, 'the reason names the boundary the call crossed') // Naming the folder is the whole remedy, and it costs no grant. assert.deepEqual( await dispatch(captured, { name: 'grep', args: { pattern: 'x', path: 'inner' }, agent: agent() }), { kind: 'allow' }, ) // The same rule reaches the other two tools whose default is a directory. assert.equal( (await dispatch(captured, { name: 'glob', args: { pattern: '**/*.ts' }, agent: agent() })).kind, 'ask', ) assert.equal( (await dispatch(captured, { name: 'recall', args: { query: 'motion learning' }, agent: agent() })).kind, 'ask', ) // A tool with no path argument at all is still left alone: it has no default // that could leave the facet. assert.deepEqual(await dispatch(captured, { name: 'todo_write', args: { todos: [] }, agent: agent() }), { kind: 'allow', }) }) test('recall is judged on the scope it names, because that is what it will search', async () => { const { captured } = harness({ config: { mode: 'deny', scopes: { 'session-1': { focus: 'inner' } } } }) assert.deepEqual(await dispatch(captured, { name: 'recall', args: { query: 'x', focus: 'inner' }, agent: agent() }), { kind: 'allow', }) assert.equal( (await dispatch(captured, { name: 'recall', args: { query: 'x', path: '..' }, agent: agent() })).kind, 'deny', ) }) test('tools the guard cannot judge are left alone, deliberately', async () => { const { captured } = harness() // A shell command cannot be read reliably from argv, so the facet is enforced // on the file tools and `bash` stays a legitimate way out. The prompt says so. assert.deepEqual( await dispatch(captured, { name: 'bash', args: { command: `cat ${root}/src/shared/types.ts` }, agent: agent() }), { kind: 'allow' }, ) assert.deepEqual(await dispatch(captured, { name: 'mcp_files', args: { path: '/etc' }, agent: agent() }), { kind: 'allow', }) }) test('an agent with no working directory is not scoped', async () => { const { captured } = harness() assert.deepEqual(await dispatch(captured, { name: 'read', args: { file_path: '/etc/hosts' } }), { kind: 'allow' }) }) test('a standing grant and a session grant both open the scope', async () => { const standing = harness({ extraRoots: ['../../shared'] }) assert.deepEqual( await dispatch(standing.captured, { name: 'read', args: { file_path: '../../shared/types.ts' }, agent: agent() }), { kind: 'allow' }, ) const granted = harness() granted.policy.grant('session-1', ['../../shared'], facet) assert.deepEqual( await dispatch(granted.captured, { name: 'read', args: { file_path: '../../shared/types.ts' }, agent: agent() }), { kind: 'allow' }, ) }) test('deny mode refuses instead of asking, and still allows the way out', async () => { const { captured } = harness({ mode: 'deny' }) const decision = await dispatch(captured, { name: 'read', args: { file_path: '../../shared/types.ts' }, agent: agent(), }) assert.equal(decision.kind, 'deny') assert.match(decision.reason, /facet action=add/) // Widening is explicit and intentional, so it is asked even here — otherwise // the user would have no way to grant anything at all. assert.equal( (await dispatch(captured, { name: 'facet', args: { action: 'add', paths: ['../../shared'] }, agent: agent() })) .kind, 'ask', ) }) test('off mode registers nothing', () => { const { captured } = harness({ mode: 'off' }) assert.equal(captured.preExecute, undefined) assert.equal(captured.section, undefined) assert.equal(captured.tool, undefined) }) test('a live mode edit decides the next call, without the guard being remounted', async () => { // The loader hands the guard the schema-resolved config, so `mode` arrives as // a volatile reference rather than a string. This drives that exact shape. const config = Config({ mode: 'ask' }) const { captured } = harness({ config }) const outside = { name: 'read', args: { file_path: '../../shared/types.ts' }, agent: agent() } assert.equal((await dispatch(captured, outside)).kind, 'ask') // What a settings write does: commit into the reference in place, and // deliberately do not re-apply the plugin. One mount, three answers. config.mode[VOLATILE_WRITE]('deny') assert.equal((await dispatch(captured, outside)).kind, 'deny') // `off` silences the guard entirely, which is the whole meaning of the mode. config.mode[VOLATILE_WRITE]('off') assert.equal((await dispatch(captured, outside)).kind, 'allow') }) test('the guard adds to a decision, never overrides one', async () => { const { captured } = harness() const denied = await dispatch(captured, { name: 'read', args: { file_path: '../../shared/types.ts' }, agent: agent(), inner: { kind: 'deny', reason: 'an earlier policy said no' }, }) assert.deepEqual(denied, { kind: 'deny', reason: 'an earlier policy said no' }) }) test('the rule is registered with no variables to interpolate', () => { const { captured } = harness() assert.equal(captured.section.name, 'facet-scope') assert.equal(captured.section.interpolate, false) assert.match(captured.section.text, /Facet scope/) }) test('the binding follows the agent, and is empty without one', () => { const { captured, policy } = harness() assert.equal(captured.context.name, 'facet-binding') assert.match(captured.context.text({ agent: agent() }), new RegExp(`facet: ${facet}$`, 'm')) policy.grant('session-1', ['../../shared'], facet) assert.match(captured.context.text({ agent: agent() }), /granted outside it: .*src\/shared/) assert.equal(captured.context.text({}), '') assert.equal(captured.context.text(undefined), '') }) test('the binding refuses honestly when the session has no approval channel', () => { // `danger-full-access` sets approval: never, so `decide()` returns "rejected" // before any answerer is consulted — and `facet action=add`, itself an ask, is // refused with it. The reason the guard writes never reaches the model on that // path (`serviceAsk` replaces it with `the user rejected tool "…"`), so the // binding is the only place the truth can be said. const { captured } = harness({ mode: 'ask', approval: 'never' }) const text = captured.context.text({ agent: agent() }) 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/) }) test('an approval channel keeps the promise, and a missing service counts as none', () => { const withChannel = harness({ mode: 'ask', approval: 'ask' }) assert.match(withChannel.captured.context.text({ agent: agent() }), /put to the user as a question/) // 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() }), /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, /context panel/) }) test('the facet tool reports the scope', async () => { const { captured } = harness() const value = await captured.tool.execute({ action: 'status' }, { agent: agent() }) assert.equal(value.ok, true) assert.equal(value.root, facet) assert.deepEqual(value.granted, []) assert.equal(value.mode, 'ask') }) test('the facet tool grants, narrows and clears', async () => { const { captured, policy } = harness() const exec = { agent: agent() } await captured.tool.execute({ action: 'add', paths: ['../../shared'] }, exec) assert.deepEqual(policy.grantsFor('session-1'), [join(root, 'src', 'shared')]) // Granting twice is a no-op, and says so rather than reporting a change. const again = await captured.tool.execute({ action: 'add', paths: ['../../shared'] }, exec) assert.match(again.note, /Already granted/) assert.deepEqual(policy.grantsFor('session-1'), [join(root, 'src', 'shared')]) await captured.tool.execute({ action: 'remove', paths: ['../../shared'] }, exec) assert.deepEqual(policy.grantsFor('session-1'), []) await captured.tool.execute({ action: 'add', paths: ['../../shared'] }, exec) await captured.tool.execute({ action: 'reset' }, exec) 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() } await assert.rejects(() => captured.tool.execute({ action: 'add' }, exec), /needs `paths`/) await assert.rejects(() => captured.tool.execute({ action: 'remove' }, exec), /needs `paths`/) await assert.rejects(() => captured.tool.execute({ action: 'explode' }, exec), /unknown action/) await assert.rejects( () => captured.tool.execute({ action: 'status' }, { agent: { session: { id: 's', header: {} } } }), /no working directory/, ) }) test('the tool can be left unregistered', () => { const { captured } = harness({ tool: false }) assert.equal(captured.tool, undefined) // The guard is the part that matters and it is still there. assert.equal(typeof captured.preExecute, 'function') }) /** * Validate a value against the tool's own declared output schema. * * The host enforces this schema before the model sees a result, and the schema * is `additionalProperties: false`, so a field leaking out of `execute` is not a * cosmetic extra — it is a failed call. The check reads the declared schema * rather than a copy of it, so the two cannot drift apart. */ function schemaErrors(schema, value, at = '$') { const errors = [] if (schema.oneOf !== undefined) { if (!schema.oneOf.some((branch) => schemaErrors(branch, value, at).length === 0)) { errors.push(`${at}: matched no oneOf branch`) } return errors } if (schema.type === 'object') { if (value === null || typeof value !== 'object' || Array.isArray(value)) { errors.push(`${at}: expected an object`) return errors } const properties = schema.properties ?? {} for (const [key, sub] of Object.entries(properties)) { if (sub.required === true && value[key] === undefined) errors.push(`${at}.${key}: required but missing`) } if (schema.additionalProperties === false) { for (const key of Object.keys(value)) { if (!(key in properties)) errors.push(`${at}.${key}: not declared, but additionalProperties is false`) } } for (const [key, sub] of Object.entries(properties)) { if (value[key] !== undefined) errors.push(...schemaErrors(sub, value[key], `${at}.${key}`)) } return errors } if (schema.type === 'array') { if (!Array.isArray(value)) return [`${at}: expected an array`] return value.flatMap((item, index) => schemaErrors(schema.items ?? {}, item, `${at}[${index}]`)) } if (schema.type === 'string' && typeof value !== 'string') return [`${at}: expected a string`] if (schema.type === 'boolean' && typeof value !== 'boolean') return [`${at}: expected a boolean`] return errors } test('every facet result satisfies the schema the model is given', async () => { const { captured } = harness() const schema = captured.tool.output.schema const exec = { agent: agent() } const results = [ await captured.tool.execute({ action: 'status' }, exec), await captured.tool.execute({ action: 'add', paths: ['../../shared'] }, exec), // The case that produces the optional `note`: granting is idempotent. await captured.tool.execute({ action: 'add', paths: ['../../shared'] }, exec), await captured.tool.execute({ action: 'remove', paths: ['../../shared'] }, exec), await captured.tool.execute({ action: 'reset' }, exec), ] for (const [index, value] of results.entries()) { assert.deepEqual(schemaErrors(schema, value), [], `result ${index} does not match the declared schema`) } // And the renderer must be a pure function of that value. for (const value of results) { const blocks = captured.tool.output.render({ action: value.action }, value) assert.equal(Array.isArray(blocks), true) assert.equal(blocks[0].type, 'text') } })