init
This commit is contained in:
commit
0858862cda
17 files changed
+2189
No files matched your search
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
/** 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. */
|
||||
function harness({ mode = 'ask', extraRoots = [], tool = true } = {}) {
|
||||
const policy = createFacetPolicy({ mode, extraRoots })
|
||||
const captured = { section: undefined, context: undefined, tool: undefined, preExecute: undefined }
|
||||
const ctx = {
|
||||
facetPolicy: policy,
|
||||
fs: fsLikeLocal,
|
||||
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(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 is in scope', async () => {
|
||||
const { captured } = harness()
|
||||
assert.deepEqual(await dispatch(captured, { name: 'glob', args: { pattern: '**/*.ts' }, agent: agent() }), {
|
||||
kind: 'allow',
|
||||
})
|
||||
})
|
||||
|
||||
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('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 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 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')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* The host row: the deployment's policy and its lifetime.
|
||||
*
|
||||
* Short, but it is the half that is easiest to leave untested — it has no
|
||||
* interesting logic and one interesting obligation, which is that a grant must
|
||||
* not outlive the session that asked for it.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { apply, Config } from '../index.js'
|
||||
|
||||
/** The smallest context this row touches. */
|
||||
function harness() {
|
||||
const captured = { provided: undefined, onDisposed: undefined }
|
||||
const ctx = {
|
||||
provide: (name, value) => {
|
||||
captured.provided = { name, value }
|
||||
},
|
||||
on: (event, listener) => {
|
||||
if (event === 'session/disposed') captured.onDisposed = listener
|
||||
},
|
||||
}
|
||||
apply(ctx, Config({ mode: 'ask' }))
|
||||
return captured
|
||||
}
|
||||
|
||||
test('the schema fills every default, so a bare row is a working row', () => {
|
||||
const config = Config({})
|
||||
assert.equal(config.mode, 'ask')
|
||||
assert.deepEqual(config.extraRoots, [])
|
||||
assert.equal(config.sectionOrder, 100)
|
||||
assert.equal(config.contextOrder, 105)
|
||||
})
|
||||
|
||||
test('a bad mode fails loudly at load rather than at the first file call', () => {
|
||||
assert.throws(() => Config({ mode: 'maybe' }))
|
||||
})
|
||||
|
||||
test('the policy is published under the name the guard injects', () => {
|
||||
const captured = harness()
|
||||
assert.equal(captured.provided.name, 'facetPolicy')
|
||||
assert.equal(captured.provided.value.mode, 'ask')
|
||||
})
|
||||
|
||||
test('the service reads the configuration it was given', () => {
|
||||
const provided = []
|
||||
apply(
|
||||
{ provide: (name, value) => provided.push({ name, value }), on: () => {} },
|
||||
Config({ mode: 'deny', extraRoots: ['../../shared'], sectionOrder: 7, contextOrder: 8 }),
|
||||
)
|
||||
const policy = provided[0].value
|
||||
assert.equal(policy.mode, 'deny')
|
||||
assert.deepEqual(policy.standing, ['../../shared'])
|
||||
assert.equal(policy.sectionOrder, 7)
|
||||
assert.equal(policy.contextOrder, 8)
|
||||
})
|
||||
|
||||
test('a disposed session does not leave its grant behind', () => {
|
||||
const captured = harness()
|
||||
const policy = captured.provided.value
|
||||
policy.grant('session-1', ['../../shared'], '/w/src/room')
|
||||
assert.deepEqual(policy.grantsFor('session-1'), ['/w/shared'])
|
||||
|
||||
assert.equal(typeof captured.onDisposed, 'function')
|
||||
captured.onDisposed({ id: 'session-1' })
|
||||
assert.deepEqual(policy.grantsFor('session-1'), [])
|
||||
assert.deepEqual(policy.sessions, [])
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { createFacetPolicy } from '../lib/policy.js'
|
||||
|
||||
/** A stand-in for the agent shape the policy reads: session id plus working directory. */
|
||||
const agentAt = (cwd, id = 's1') => ({ session: { id, header: { cwd } } })
|
||||
|
||||
test('the scope is the session directory, and nothing else', () => {
|
||||
const policy = createFacetPolicy({ mode: 'ask' })
|
||||
const scope = policy.scopeFor(agentAt('/w/src/controllers/room'))
|
||||
assert.equal(scope.root, '/w/src/controllers/room')
|
||||
assert.equal(scope.cwd, scope.root)
|
||||
assert.deepEqual(scope.extras, [])
|
||||
})
|
||||
|
||||
test('an agent with no working directory has no facet', () => {
|
||||
const policy = createFacetPolicy({ mode: 'ask' })
|
||||
assert.equal(policy.scopeFor(undefined), undefined)
|
||||
assert.equal(policy.scopeFor({}), undefined)
|
||||
assert.equal(policy.scopeFor({ session: { header: {} } }), undefined)
|
||||
assert.equal(policy.scopeFor({ session: { header: { cwd: ' ' } } }), undefined)
|
||||
assert.equal(policy.scopeFor({ session: { header: { cwd: 7 } } }), undefined)
|
||||
})
|
||||
|
||||
test('a grant widens one session and leaves every other alone', () => {
|
||||
const policy = createFacetPolicy({ mode: 'ask' })
|
||||
policy.grant('s1', ['../shared'], '/w/src/room')
|
||||
|
||||
assert.deepEqual(policy.scopeFor(agentAt('/w/src/room', 's1')).extras, ['/w/src/shared'])
|
||||
assert.deepEqual(policy.scopeFor(agentAt('/w/src/room', 's2')).extras, [])
|
||||
assert.deepEqual(policy.grantsFor('s1'), ['/w/src/shared'])
|
||||
assert.deepEqual(policy.grantsFor('s2'), [])
|
||||
})
|
||||
|
||||
test('grants are absolute, deduplicated and order-preserving', () => {
|
||||
const policy = createFacetPolicy({ mode: 'ask' })
|
||||
// `shared` is relative to the session directory, `/w/src/shared` is not, and
|
||||
// both are kept: they are different places that happen to share a name.
|
||||
policy.grant('s1', ['shared', '/w/src/shared', 'shared', 'other'], '/w/src/room')
|
||||
assert.deepEqual(policy.grantsFor('s1'), ['/w/src/room/shared', '/w/src/shared', '/w/src/room/other'])
|
||||
})
|
||||
|
||||
test('revoke and reset narrow without touching anyone else', () => {
|
||||
const policy = createFacetPolicy({ mode: 'ask' })
|
||||
policy.grant('s1', ['a', 'b'], '/w')
|
||||
policy.grant('s2', ['c'], '/w')
|
||||
|
||||
assert.deepEqual(policy.revoke('s1', ['a'], '/w'), ['/w/b'])
|
||||
assert.deepEqual(policy.revoke('s1', ['b'], '/w'), [])
|
||||
assert.equal(policy.sessions.includes('s1'), false)
|
||||
assert.deepEqual(policy.grantsFor('s2'), ['/w/c'])
|
||||
|
||||
policy.grant('s3', ['d'], '/w')
|
||||
assert.deepEqual(policy.reset('s3'), [])
|
||||
assert.deepEqual(policy.grantsFor('s3'), [])
|
||||
})
|
||||
|
||||
test('a disposed session does not leave its grant behind', () => {
|
||||
const policy = createFacetPolicy({ mode: 'ask' })
|
||||
policy.grant('s1', ['shared'], '/w')
|
||||
assert.deepEqual(policy.sessions, ['s1'])
|
||||
|
||||
policy.forget('s1')
|
||||
assert.deepEqual(policy.sessions, [])
|
||||
// A resumed session id must not inherit a grant nobody remembers making.
|
||||
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, [])
|
||||
})
|
||||
|
||||
test('standing grants resolve against each session, not the process', () => {
|
||||
const policy = createFacetPolicy({ mode: 'ask', extraRoots: ['../shared', '/abs/common'] })
|
||||
assert.deepEqual(policy.scopeFor(agentAt('/w/a/one')).extras, ['/w/a/shared', '/abs/common'])
|
||||
assert.deepEqual(policy.scopeFor(agentAt('/w/b/two')).extras, ['/w/b/shared', '/abs/common'])
|
||||
})
|
||||
|
||||
test('junk in the configuration and in a grant is dropped, not thrown', () => {
|
||||
const policy = createFacetPolicy({ mode: 'ask', extraRoots: ['ok', '', ' ', 42, null] })
|
||||
assert.deepEqual(policy.standing, ['ok'])
|
||||
|
||||
policy.grant('s1', ['', ' ', 42, null, 'real'], '/w')
|
||||
assert.deepEqual(policy.grantsFor('s1'), ['/w/real'])
|
||||
})
|
||||
|
||||
test('standing grants and session grants are merged without repetition', () => {
|
||||
const policy = createFacetPolicy({ mode: 'ask', extraRoots: ['shared'] })
|
||||
policy.grant('s1', ['shared', 'other'], '/w')
|
||||
assert.deepEqual(policy.scopeFor(agentAt('/w', 's1')).extras, ['/w/shared', '/w/other'])
|
||||
})
|
||||
|
||||
test('prompt placements are carried, so the preset row needs no config', () => {
|
||||
assert.equal(createFacetPolicy({ mode: 'ask' }).sectionOrder, 100)
|
||||
assert.equal(createFacetPolicy({ mode: 'ask' }).contextOrder, 105)
|
||||
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).sectionOrder, 7)
|
||||
assert.equal(createFacetPolicy({ mode: 'ask', sectionOrder: 7, contextOrder: 8 }).contextOrder, 8)
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import {
|
||||
displayPath,
|
||||
facetContextText,
|
||||
facetRuleText,
|
||||
isWideningCall,
|
||||
outsideReason,
|
||||
pathArgumentsFor,
|
||||
widenReason,
|
||||
} from '../lib/scope.js'
|
||||
|
||||
test('pathArgumentsFor reads the path argument of every guarded tool', () => {
|
||||
assert.deepEqual(pathArgumentsFor('read', { file_path: 'src/a.ts' }), ['src/a.ts'])
|
||||
assert.deepEqual(pathArgumentsFor('write', { file_path: 'src/a.ts', content: 'x' }), ['src/a.ts'])
|
||||
assert.deepEqual(pathArgumentsFor('edit', { file_path: 'src/a.ts' }), ['src/a.ts'])
|
||||
assert.deepEqual(pathArgumentsFor('read_image', { file_path: 'a.png' }), ['a.png'])
|
||||
assert.deepEqual(pathArgumentsFor('grep', { pattern: 'x', path: 'src' }), ['src'])
|
||||
assert.deepEqual(pathArgumentsFor('glob', { pattern: '*.ts', path: 'src' }), ['src'])
|
||||
assert.deepEqual(pathArgumentsFor('str_replace_editor', { path: 'src/a.ts' }), ['src/a.ts'])
|
||||
assert.deepEqual(pathArgumentsFor('recall', { path: 'src', focus: 'src/controllers' }), [
|
||||
'src',
|
||||
'src/controllers',
|
||||
])
|
||||
})
|
||||
|
||||
test('pathArgumentsFor has no opinion where it cannot have one', () => {
|
||||
// The deliberate hole: a shell command cannot be judged from argv.
|
||||
assert.deepEqual(pathArgumentsFor('bash', { command: 'cat /etc/passwd' }), [])
|
||||
// Unknown tools are not guessed at.
|
||||
assert.deepEqual(pathArgumentsFor('mcp_something', { path: '/etc' }), [])
|
||||
// An omitted path means the session directory, which is already in scope.
|
||||
assert.deepEqual(pathArgumentsFor('grep', { pattern: 'x' }), [])
|
||||
// Unvalidated JSON must never make the guard throw.
|
||||
assert.deepEqual(pathArgumentsFor('read', null), [])
|
||||
assert.deepEqual(pathArgumentsFor('read', 'nonsense'), [])
|
||||
assert.deepEqual(pathArgumentsFor('read', { file_path: 42 }), [])
|
||||
assert.deepEqual(pathArgumentsFor('read', { file_path: ' ' }), [])
|
||||
assert.deepEqual(pathArgumentsFor('grep', { path: { nested: '/etc' } }), [])
|
||||
})
|
||||
|
||||
test('isWideningCall is true only for the one act that needs the user', () => {
|
||||
assert.equal(isWideningCall('facet', { action: 'add', paths: ['x'] }), true)
|
||||
// Narrowing and reading cannot show the agent anything new.
|
||||
assert.equal(isWideningCall('facet', { action: 'remove', paths: ['x'] }), false)
|
||||
assert.equal(isWideningCall('facet', { action: 'reset' }), false)
|
||||
assert.equal(isWideningCall('facet', { action: 'status' }), false)
|
||||
assert.equal(isWideningCall('read', { action: 'add' }), false)
|
||||
assert.equal(isWideningCall('facet', null), false)
|
||||
})
|
||||
|
||||
test('facetRuleText states the rule and never the binding', () => {
|
||||
const ask = facetRuleText({ mode: 'ask' })
|
||||
assert.match(ask, /put to the user as a question/)
|
||||
// The two modes must not read alike: ask must not claim the call is refused.
|
||||
assert.doesNotMatch(ask, /A call outside it is refused/)
|
||||
|
||||
const deny = facetRuleText({ mode: 'deny' })
|
||||
assert.match(deny, /A call outside it is refused/)
|
||||
|
||||
// The rule must not name a path: a changing system prompt invalidates the
|
||||
// prompt cache for the whole conversation.
|
||||
assert.doesNotMatch(ask, /\/|src\//)
|
||||
|
||||
assert.equal(facetRuleText({ mode: 'off' }), '')
|
||||
assert.equal(facetContextText({ cwd: '/a', root: '/a/b', extras: [], mode: 'off' }), '')
|
||||
})
|
||||
|
||||
test('facetContextText carries the binding that does change', () => {
|
||||
assert.equal(
|
||||
facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }),
|
||||
'facet: /w/src/room\noutside the facet: ask the user',
|
||||
)
|
||||
assert.equal(
|
||||
facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' }),
|
||||
'facet: /w/src/room\ngranted outside it: src/shared\noutside the facet: refused',
|
||||
)
|
||||
// Unscoped sessions contribute nothing rather than an empty heading.
|
||||
assert.equal(facetContextText({ cwd: '/w', root: undefined, extras: [], mode: 'ask' }), '')
|
||||
})
|
||||
|
||||
test('displayPath prefers the shortest unambiguous form', () => {
|
||||
assert.equal(displayPath('/w', '/w/src/a.ts'), 'src/a.ts')
|
||||
assert.equal(displayPath('/w', '/w'), '.')
|
||||
assert.equal(displayPath('/w', '/other/a.ts'), '/other/a.ts')
|
||||
assert.equal(displayPath('', '/w/a.ts'), '/w/a.ts')
|
||||
})
|
||||
|
||||
test('outsideReason names the path, the boundary and the way forward', () => {
|
||||
const reason = outsideReason({
|
||||
cwd: '/w/src/controllers/room',
|
||||
path: '/w/src/shared/types.ts',
|
||||
root: '/w/src/controllers/room',
|
||||
mode: 'ask',
|
||||
})
|
||||
assert.match(reason, /src\/shared\/types\.ts/)
|
||||
assert.match(reason, /src\/controllers\/room/)
|
||||
// A denial that does not say how to proceed is one the agent routes around.
|
||||
assert.match(reason, /facet action=add/)
|
||||
})
|
||||
|
||||
test('widenReason survives an add with no usable paths', () => {
|
||||
assert.match(widenReason(['src/shared']), /src\/shared/)
|
||||
assert.match(widenReason([]), /nothing/)
|
||||
})
|
||||
Reference in new issue
Block a user