Files
dsh-facet/test/guard.test.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

539 lines
22 KiB
JavaScript

/**
* 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')
}
})