The facet root was the session's working directory and nothing else, so the only way to scope a conversation to a module was to make the module a workspace — and the only way to reach anything outside it was `extraRoots`, which is a fact about the deployment rather than about one conversation. Scope now has two layers, and the split is the point. The **user layer** is `config.scopes[sessionId]` — a `focus` and a list of `extras`, written only from the browser context panel through the settings service, which is what `settings.mutate` requiring a volatile path buys us. The **agent layer** is in memory: a narrowing `focus` and its grants. Both can only ever reduce what the user layer allows, so an agent that goes wrong cannot widen its own scope. `facet action=focus` is therefore the one scope change the agent may make unasked, bounded by the scope already in force and decided on canonical targets. `reset` clears the agent's side only, landing on the user's declaration rather than on the session directory — landing on the directory would have made `reset` a widening. `extraRoots` demotes to an optional deployment-wide seed. The binding now names the context panel as the way to widen, because it is: in a deployment with no approval channel, `facet action=add` cannot be granted at all.
143 lines
6.6 KiB
JavaScript
143 lines
6.6 KiB
JavaScript
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 rule = facetRuleText()
|
|
|
|
// Mode-neutral by construction. A prompt section is registered once as a plain
|
|
// string, and a `volatile()` edit updates the config reference without
|
|
// re-applying the plugin — so a `mode` interpolated here would freeze at load
|
|
// while the guard went on enforcing the new one. The mode-specific sentence
|
|
// lives in the binding, which is a callback.
|
|
assert.match(rule, /Work inside the facet\./)
|
|
assert.doesNotMatch(rule, /refused|put to the user as a question/)
|
|
assert.match(rule, /Never widen the scope on your own initiative/)
|
|
|
|
// The rule must not name a path: a changing system prompt invalidates the
|
|
// prompt cache for the whole conversation.
|
|
assert.doesNotMatch(rule, /\/|src\//)
|
|
|
|
assert.equal(facetContextText({ cwd: '/a', root: '/a/b', extras: [], mode: 'off' }), '')
|
|
})
|
|
|
|
test('facetContextText carries the binding that does change', () => {
|
|
const ask = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' })
|
|
assert.equal(
|
|
ask,
|
|
'facet: /w/src/room\n' +
|
|
'outside the facet: put to the user as a question — say in one line what you need and why, then record it with `facet action=add` so the rest of the session does not have to ask again',
|
|
)
|
|
|
|
const deny = facetContextText({ cwd: '/w', root: '/w/src/room', extras: ['/w/src/shared'], mode: 'deny' })
|
|
assert.equal(
|
|
deny,
|
|
'facet: /w/src/room\n' +
|
|
'granted outside it: src/shared\n' +
|
|
'outside the facet: refused — ask the user to widen the facet from its context panel, or with `facet action=add` where that can be granted',
|
|
)
|
|
|
|
// The binding is the only half that can follow a live edit, so the two modes
|
|
// have to read differently here — they no longer differ in the rule.
|
|
assert.notEqual(ask, deny)
|
|
|
|
// Unscoped sessions contribute nothing rather than an empty heading.
|
|
assert.equal(facetContextText({ cwd: '/w', root: undefined, extras: [], mode: 'ask' }), '')
|
|
assert.equal(facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'off' }), '')
|
|
})
|
|
|
|
test('a session with no approval channel says so instead of promising a question', () => {
|
|
// `approval: never` refuses every ask before an answerer is consulted, so the
|
|
// mode stops mattering: `ask` and `deny` both end in a refusal, and
|
|
// `facet action=add` cannot be granted either. `canAsk: false` is the one
|
|
// input that collapses them, and it has to name the mechanism that still
|
|
// works — the context panel, which the user owns.
|
|
for (const mode of ['ask', 'deny']) {
|
|
const text = facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode, canAsk: false })
|
|
assert.match(text, /outside the facet: refused — this session has no approval channel/)
|
|
assert.match(text, /`facet action=add` cannot be granted/)
|
|
assert.match(text, /context panel/)
|
|
assert.doesNotMatch(text, /put to the user as a question/)
|
|
}
|
|
|
|
// The default stays a promise: a caller that knows nothing about approval
|
|
// channels must not silently change what the binding says.
|
|
assert.match(
|
|
facetContextText({ cwd: '/w', root: '/w/src/room', extras: [], mode: 'ask' }),
|
|
/put to the user as a question/,
|
|
)
|
|
})
|
|
|
|
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/)
|
|
})
|