Release / release (push) Successful in 6s
The user's declaration is keyed by session id, and a session spawned or forked from a scoped one has none of its own — so its facet was its own working directory, one level above its parent's. Delegation was therefore a way around the scope rather than work inside it: the parent could not read the file itself and could have a subagent read it. A session now inherits the root resolved for the session named by its `parentSession` header, which the harness already records durably for both a spawned child and a fork. The inherited root is the parent's *effective* root, so it already includes any narrowing the parent made for itself: delegating is not a way back out of a self-imposed focus either. Because each session records its own root as it resolves a scope, and a scope is resolved for every model request and every tool call, inheritance holds at any depth of delegation rather than for one hop. Two boundaries are deliberate. A declaration the user made *for the child* is a more specific statement about that session and stands — inheritance only answers the case where they made none. And a parent's recorded root outlives its session while any child that inherited it is still running, because disposing a parent must not quietly widen a subagent that is still working.
617 lines
26 KiB
JavaScript
617 lines
26 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' })
|
|
})
|
|
|
|
// ── delegated sessions ───────────────────────────────────────────────────────
|
|
//
|
|
// A session spawned or forked from a scoped one has no declaration of its own,
|
|
// and the user's declaration is keyed by session id. Without inheritance its
|
|
// facet would be its own working directory — wider than the conversation it came
|
|
// from, which is the one direction this plugin promises cannot happen.
|
|
|
|
/** A session that was spawned from another, as the harness records lineage. */
|
|
const childOf = (parentId, id = 'child-1', cwd = facet) => ({
|
|
session: { id, header: { cwd, parentSession: parentId } },
|
|
})
|
|
|
|
test('a session spawned from a scoped one starts inside the same facet', async () => {
|
|
const { captured } = harness({ config: { mode: 'ask', scopes: { 'session-1': { focus: 'inner' } } } })
|
|
|
|
// The parent's root is on the record as soon as it resolves a scope, which
|
|
// happens for every model request and every tool call — so before it can
|
|
// spawn anything.
|
|
assert.deepEqual(
|
|
await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: agent() }),
|
|
{ kind: 'allow' },
|
|
)
|
|
|
|
const child = childOf('session-1')
|
|
// Without inheritance this would be ordinary work for the child: its root
|
|
// would be the session directory, one level up from its parent's facet.
|
|
const decision = await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: child })
|
|
assert.equal(decision.kind, 'ask')
|
|
assert.match(decision.reason, /inner/)
|
|
// Inside the inherited facet is inside the child's scope, as it should be.
|
|
assert.deepEqual(
|
|
await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: child }),
|
|
{ kind: 'allow' },
|
|
)
|
|
})
|
|
|
|
test('inheritance holds at depth, and is not a way around a self-narrowing', async () => {
|
|
// No declaration: the user left the session at its directory, and the only
|
|
// thing that will keep a child inside `inner` is the inherited root.
|
|
const { captured } = harness({ config: { mode: 'deny' } })
|
|
await captured.tool.execute({ action: 'focus', paths: ['inner'] }, { agent: agent() })
|
|
|
|
const child = childOf('session-1')
|
|
const grandchild = childOf('child-1', 'grand-1')
|
|
// The child's own scope is resolved by its first call, which is what puts its
|
|
// root on the record for the grandchild to inherit.
|
|
for (const descendant of [child, grandchild]) {
|
|
assert.equal(
|
|
(await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: descendant })).kind,
|
|
'allow',
|
|
`${descendant.session.id} is inside the inherited facet`,
|
|
)
|
|
assert.equal(
|
|
(await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: descendant })).kind,
|
|
'deny',
|
|
`${descendant.session.id} cannot delegate its way back out of the narrowing`,
|
|
)
|
|
}
|
|
})
|
|
|
|
test('a declaration for the child itself stands over what it would inherit', async () => {
|
|
const { captured } = harness({
|
|
config: { mode: 'ask', scopes: { 'session-1': { focus: 'inner' }, 'child-1': { focus: '.' } } },
|
|
})
|
|
assert.deepEqual(
|
|
await dispatch(captured, { name: 'read', args: { file_path: 'inner/index.ts' }, agent: agent() }),
|
|
{ kind: 'allow' },
|
|
)
|
|
|
|
// The user is the authority on this session too: a declaration made for the
|
|
// child is a statement about the child, and inheritance only answers the case
|
|
// where they made none.
|
|
assert.deepEqual(
|
|
await dispatch(captured, { name: 'read', args: { file_path: 'index.ts' }, agent: childOf('session-1') }),
|
|
{ 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')
|
|
}
|
|
})
|