# A path policy runs before discovery or reading

> Source: https://github.com/max-listov/stitchkit/blob/master/docs/decisions/0172-a-path-policy-runs-before-discovery-or-reading.md

---

# 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 `.env` or 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_command` is 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
  `isManagedFilePath` already 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.
- **`authorizePath` denial 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: denying `credentials` must not deny `credentials-backup`. The workspace root is not
  asked about — asking `.` on every direct access inverts every allow-list, which answers `false`
  for a root it never meant to deny. The required `authorize({ operation, path })` is unchanged and
  still answers once per operation; that asymmetry is the reason this callback exists, and it means
  a host relying on `authorize` alone has no per-file decision during `search_files` or `glob`.
  **The policy must therefore admit every directory on the way to a file**, the way POSIX needs
  `+x` on 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 `FORBIDDEN` rather than `NOT_FOUND`. The policy runs before any filesystem
  access, so `FORBIDDEN` discloses the host's own rule to the host's own agent, not disk state — and
  an honest `NOT_FOUND` would require touching the disk under a denied ancestor, which is the one
  thing the policy exists to prevent.
- `run_command` remains outside the path policy: an executable needs process isolation, not path
  filtering. It is not outside the *shape* rule — `cwd` now 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.
