0172 — A path policy runs before discovery or reading
Decision
createAgentCodingTools accepts an optional async or synchronous authorizePath({ path })
callback. It is operation-independent: the same decision gates direct read, write and edit,
directory listing, glob and both search modes. Direct access to a denied path returns the ordinary
typed FORBIDDEN refusal; discovery omits the denied entry without revealing that it exists.
The contained walker asks the callback about a directory before descending and about a file before
opening it. A search_files.include matcher is part of that pre-read selection, so an excluded file
is not opened merely because filtering the result would later remove it. Descriptor-relative
containment remains the filesystem authority after admission, and symlinks remain refused or
skipped rather than becoming another policy resolution path.
The existing authorize(operation) callback stays responsible for the requested effect, query and
mutation facts. authorizePath answers the separate question that all file tools share: whether a
workspace-relative path may be disclosed or touched at all.
Why
A single authorization for a broad search cannot authorize each file the scanner will encounter. Filtering matches after a content scan is too late: every file has already been opened and read. Directory-only exclusions are also not a credential policy, because protected files may live beside ordinary source and direct read/write must agree with search.
Making every consumer copy the scanner would split containment, bounds and symlink behavior. Making path admission another operation in the existing discriminated union would turn an additive policy into a breaking exhaustive-match change. An optional callback adds the missing boundary without a second file engine or a compatibility name.
Consequences
- Hosts can express one
.envor credential-path rule for every direct and broad file surface. - A denied path is indistinguishable from an absent path in discovery output, while direct access is explicitly refused.
- The callback can use asynchronous policy state; traversal awaits each decision before opening the selected entry.
run_commandis unchanged: an allowed executable is not an OS sandbox and can only be constrained by process isolation and the executable contract.
Amendment (0.84.0): denial is recursive, and the path has one spelling
The decision above said the callback is asked “about a directory before descending and about a
file before opening it”. That described the walk, and the walk alone. Direct read, write and edit
asked only about the leaf, so one rule meant two things: path !== 'credentials' hid the directory
from glob and served credentials/token.txt to read_file, created files under it through
write_file, and rewrote it through edit_file. list_directory and glob given a base path
inside a denied directory disclosed it too, and disagreed with each other about the refusal shape.
Underneath that sat a defect older than this ADR. contained-files splits a relative path on
[\\/], so credentials\\token.txt reaches openat as two segments — while every callback was
handed the string whole and read it as one name. That bypassed the required
authorize({ operation, path }), which every consumer has had since 0.70.0, for reads and for
writes alike: a rule denying credentials/token.txt refused that spelling and served, then
overwrote, the same file spelled with a backslash.
So two things are now part of the decision rather than of one implementation:
- A path has one spelling. A backslash in a requested path is refused, the way
isManagedFilePathalready refuses it for managed files. Refused rather than normalised: a backslash is a legal character in a Linux filename, and normalising would silently redefine which file the caller named — while the walk has read it as a separator all along, so such a file was never reachable through these tools. authorizePathdenial is recursive. Direct access and discovery base paths ask about each ancestor of the path, outermost first, short-circuiting on the first refusal. Segment-wise, not by string prefix: denyingcredentialsmust not denycredentials-backup. The workspace root is not asked about — asking.on every direct access inverts every allow-list, which answersfalsefor a root it never meant to deny. The requiredauthorize({ operation, path })is unchanged and still answers once per operation; that asymmetry is the reason this callback exists, and it means a host relying onauthorizealone has no per-file decision duringsearch_filesorglob. The policy must therefore admit every directory on the way to a file, the way POSIX needs+xon each directory in a path — a deny-list gets this for free, an allow-list must name the directories it leads through.
Consequences of the amendment:
- A path of depth N costs N+1 policy questions, and a host keeping an audit no longer sees the leaf once an ancestor has refused. Both are the price of one meaning instead of two.
- The refusal stays
FORBIDDENrather thanNOT_FOUND. The policy runs before any filesystem access, soFORBIDDENdiscloses the host’s own rule to the host’s own agent, not disk state — and an honestNOT_FOUNDwould require touching the disk under a denied ancestor, which is the one thing the policy exists to prevent. run_commandremains outside the path policy: an executable needs process isolation, not path filtering. It is not outside the shape rule —cwdnow goes through the same segment and separator validation as every file tool, because accepting spellings there that the file tools refuse was an asymmetry with no decision behind it.