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

+16 -1
View File
@@ -37,6 +37,8 @@ import {
FACET_TOOL,
facetContextText,
facetRuleText,
IMPLICIT_SESSION_PATHS,
implicitSearchReason,
isWideningCall,
outsideReason,
pathArgumentsFor,
@@ -181,7 +183,20 @@ export function apply(ctx, config) {
if (scope === undefined) return undefined
const paths = pathArgumentsFor(exec.name, exec.arguments)
if (paths.length === 0) return undefined
if (paths.length === 0) {
// A tool that names no path is judged against the path it will actually
// use. For most of them that is nothing — a tool with no path argument
// cannot leave the facet. For the ones whose own default is a directory it
// is the session directory, which a narrowed facet does not contain, and
// "no opinion" there would hand the model a read the guard exists to
// refuse. The containment test is the same one; only the path is ours
// rather than the model's.
if (!IMPLICIT_SESSION_PATHS.includes(exec.name)) return undefined
const verdict = await inside(scope, scope.cwd, exec.signal)
if (verdict.inside) return undefined
const kind = mode === 'deny' ? 'deny' : 'ask'
return { kind, reason: implicitSearchReason({ cwd: scope.cwd, root: scope.root }) }
}
for (const raw of paths) {
const verdict = await inside(scope, raw, exec.signal)
+49 -1
View File
@@ -54,6 +54,28 @@ export const PATH_ARGUMENTS = Object.freeze({
recall: ['path', 'focus'],
})
/**
* Tools that will search the session directory when the model names no path.
*
* The guard's rule is that a call naming no path is not judged, because a tool
* that names no path cannot leave the facet. That holds for a tool with no path
* argument at all, and fails for these three: their *own* default is the session
* workspace. `grep` and `glob` document both their path arguments as "Defaults to
* the session workspace", and `recall` falls back to a directory its own marker
* search picks — an ancestor of the facet whenever the facet carries no
* `CONTROLLER.md` of its own.
*
* So 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` reads outside the facet. An absent argument is not
* an absent path; it is the default one, and that is the path the guard judges.
*
* `recall` is another package's tool and is named here because the coupling to
* its two path arguments already exists in {@link PATH_ARGUMENTS}: leaving it
* out would keep a read path the table claims to cover.
*/
export const IMPLICIT_SESSION_PATHS = Object.freeze(['grep', 'glob', 'recall'])
/** The tool this plugin registers, and the action on it that widens the scope. */
export const FACET_TOOL = 'facet'
export const WIDENING_ACTION = 'add'
@@ -66,7 +88,10 @@ export const WIDENING_ACTION = 'add'
* Anything that is not a non-empty string is skipped.
*
* An empty result means "no opinion" — either the tool names no path (so it
* cannot leave the facet) or it names one in a shape we refuse to guess at.
* cannot leave the facet) or it names one in a shape we refuse to guess at. The
* first half of that is not true of every tool: the ones whose own default is a
* directory are listed in {@link IMPLICIT_SESSION_PATHS}, and the guard judges
* them against the directory they will actually search.
*
* @param {string} toolName - the tool being called.
* @param {unknown} args - the raw model arguments.
@@ -236,6 +261,29 @@ export function outsideReason(input) {
return `${what} is ${verb} — this session's facet is ${root}. Ask the user to grant it (\`facet action=add\`), or work within the facet.`
}
/**
* Why a search that named no directory is being questioned.
*
* A separate sentence from {@link outsideReason} because the fault is different.
* There, the model named a path outside the facet and is told which one. Here it
* named nothing, so the path at issue is one it never wrote: the tool's own
* default. Saying so is the whole remedy — the fix is a second argument, not a
* grant — so the reason names the directory the search would have covered and
* the one it had to stay in.
*
* @param {object} input
* @param {string} input.cwd - the session working directory the tool defaults to.
* @param {string} input.root - the facet root.
* @returns {string} the reason.
*/
export function implicitSearchReason(input) {
const { cwd, root } = input
return (
`This search names no \`path\`, so it would cover this session's directory ${cwd} — and this session's ` +
`facet is ${root}. Name a folder inside the facet, or ask the user to grant the one you need.`
)
}
/**
* Why widening the scope is being put to the user.
*