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.
This commit is contained in:
NotExpectedYet committed 2026-09-27 07:42:54 +01:00
1 parent b0902017fd
commit a9a25490db
4 files changed
+137 -13

No files matched your search

+47 -1
View File
@@ -170,13 +170,59 @@ test('a prefix is not a parent', async () => {
assert.equal(decision.kind, 'ask')
})
test('an omitted path means the session directory, which is in scope', async () => {
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