Files
dsh-facet/test/scope.test.js
T
NotExpectedYet a1c8ecb1e8
Release / release (push) Successful in 6s
fix: say so when a session has no approval channel to ask through
`ask` mode promises that an out-of-facet call is put to the user, and that
promise needs a channel. A session can be composed without one: the
`danger-full-access` permission preset sets `approval: never`, and the
approval service's `decide()` then returns "rejected" before consulting any
answerer. The call is refused, `facet action=add` is refused with it — widening
is itself a question — and the scope can never be widened.

The model is told none of that. `serviceAsk` replaces a rejected ask with
`the user rejected tool \"…\"`, discarding the reason this guard wrote, so the
binding is the only channel left that can state what is true. It now reads the
session's effective approval policy and, when there is no channel, says the call
was refused rather than asked and names the mechanism that still works:
`extraRoots` is configuration applied by the profile, not a grant asked for per
conversation.

A missing approval service counts as no channel (the tool pipeline denies an ask
it cannot route); an unreadable policy counts as a channel, so a transient
failure never tells a user to go and edit their configuration.
2026-09-26 09:02:48 +01:00

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 — `facet action=add` asks the user to grant a path, and is the only way to widen the scope',
)
// 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 approved either. `canAsk: false` is the one
// input that collapses them, and it has to name the mechanism that still
// works — `extraRoots` is configuration, not a per-conversation grant.
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 — approval prompts are off in this session/)
assert.match(text, /`facet action=add` cannot be approved either/)
assert.match(text, /`extraRoots`/)
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/)
})