Learn what a directory policy should be, without learning the wrong tree - #91
Merged
Conversation
added 2 commits
September 4, 2026 20:44
`Write` and `Edit` clusters refused as `no-matcher-shape`, so file-path policy could only be hand-written. They now have a shape and a lane. The shell lane's five-part non-widening argument does not transfer, and every analogous question has the opposite answer, so the lane is built from scratch: the unit is a path segment rather than a word, widening runs towards *shorter* prefixes rather than longer argument lists, the recorded string may be absolute or relative for the same file, and a path that looks confined may not be. - `generalise.ts` gains `PATH_TOOLS` and `normalisedPath` — one normalisation seam, in the module `models.ts` already names for it. - `mine.ts` shapes a path-carrying record by its first two directory segments below `cwd`, so relative and absolute records for one file cluster together. - `propose.ts` emits an anchored matcher requiring a literal `/` after the directory, refuses below a two-segment floor, and refuses any cluster whose evidence did not resolve to where it was written. - The clause body and `docs/KNOWLEDGE.md` state that it is a textual guard and not a substitute for `assertWritable`.
# Conflicts: # plugin/lib/policy/propose.js # src/policy/propose.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this closes
Issue #83.
WriteandEditclusters refused asno-matcher-shapeatsrc/policy/propose.ts:89, sofile-path policy could only ever be hand-written. They now have a shape and a lane.
Base
origin/main(fd87f2a). No dependency onss/learned-yellow— the gap lane it needs isalready merged.
The shell matcher does not transfer, and the reframing matters first
The issue's motivating example is "never edit anything under
infra/prod/", which is a red. Thispipeline never proposes a red or an orange — proposing a safety clause from the absence of one
manufactures a deny from silence (§4.7) — so that clause is not what this lane learns, and saying so
up front avoids a reviewer looking for it. What it learns is the two levels the lanes can produce:
infra/prod/without a classifier round-trip";
infra/prod/", whichcan only withhold an allow a learned green would have granted.
With that fixed, every one of the shell lane's five non-widening arguments had to be rebuilt, and each
analogous question has the opposite answer.
The unit is a segment, not a word
commandMatcherends its literal on(?=[\s"\\]). That character class says nothing at all about/,so it would have matched
infra/production-notes/andinfra/prod.bak/for a literalinfra/prod.The path matcher's boundary is a literal
/:Requiring the
/also means the directory entry itself is not a file under the directory. The leftanchor is the shell lane's, unchanged in spirit: the literal starts at the first character of the path
value, keyed on the tool's own argument name — so no left slack, and a
Write'scontent(whichhaystackForincludes for a red clause) cannot satisfy a green path clause by merely mentioning thedirectory.
Widening runs the other way, so the floor refuses instead of falling back
For a command, a longer argument list is the widening, so E4 shrinks a token at a time until every
segment accepts the prefix. For a path a shorter prefix is the widening, and a directory rule's
natural form is a prefix — so there is nothing to shrink towards.
commonPathLiteralrefuses belowthe floor rather than narrowing onto it, the same shape as E8: dropped, not narrowed.
The floor is two path segments below the recorded
cwd(PATH_FLOOR_SEGMENTS). It is the samenumber as
canonicalSegment's two tokens, for the same reason: one token is a whole tool (git,npm) and clusters everything it can do into one rule; one segment is a whole top-level tree (src,docs,infra) and does the same. The rule people actually mean lives one level down —infra/prod,not
infra— exactly where the subcommand lives in the shell lane. It refuses the repository root anda single component such as
srcby construction, and the cost is real and accepted: "allow writesunder
docs/" cannot be learned. Erring narrow is the codebase's stated position (tierFor) and adepth-1 grant in a monorepo is the whole tree.
The string is not stable, so there is one normaliser and it lives at the named seam
models.ts:272already says the normalised call shape "belongs withsrc/policy/generalise.ts, nothere — this field is the seam it reads". So
PATH_TOOLSandnormalisedPathare there, not inmine.tsand not inpropose.ts. There was no prior path normaliser anywhere in the tree —git grep'd for it, andgeneralise.ts's existing content is Claude Code rule emission, not pathhandling — so this extends the designated seam rather than adding a second one.
normalisedPathrequirescwdfor a relative path and never defaults it:path.resolvewould fallback to
process.cwd(), which is the miner's directory, and would resolve two records for the samefile to two different strings — the exact bug. A record that wrote
infra/prod/net.tfand one thatwrote
/w/api/infra/prod/net.tfnow land in one cluster with one variant, tested end to end.One escaper for the whole system stays true:
escapeForMatcherloosens whitespace to\s+, which isright for a command and wrong for a path (
a banda bare two directories), so a whitespaceliteral is refused rather than escaped by a second escaper
practices.tsasks nobody to write.Symlinks: the cluster is refused, and the clause says it cannot resolve one
The matcher is textual, and there is no way around that: the runtime haystack is
haystackFor(tool, input), the written string. A matcher over the resolved path would never matchand would be inert; a matcher over the written string is bypassable through a symlink.
So the decision is refuse the cluster (
path-symlinked) when any path in the evidence does notresolve to where it was written. It reuses
learnedClauses.ts's ownrealpathOf— exported for this,including its deepest-existing-ancestor recursion for a not-yet-existing file — because two
definitions of "where does this path really go" is what makes a write boundary porous.
The comparison is of relative positions, not absolute strings. On macOS
/tmpis a symlink to/private/tmp, so an absolute comparison would refuse every candidate whose session ran under atemporary directory. There is a test named for that.
This is a check on the evidence, at mining time, and the limit is stated where readers will hit
it: in the clause body itself, in
docs/KNOWLEDGE.mdunder a heading that says it, and inplugin/commands/learn.mdso the agent describing a run cannot call it containment. A symlink createdafter a clause is accepted is invisible to it, and
assertWritableremains the only filesystemboundary in the system.
The tool set, enumerated from the code
session.ts'sPAYLOAD_KEYSenumerates the write tools by the bytes they carry, so the set isderived rather than guessed:
content→Write,old_string/new_string→Edit,edits→MultiEdit,new_source→NotebookEdit.NotebookEditusesnotebook_path, so the key isper-tool and a matcher never matches a path another tool sends under another key.
Excluded, each for a reason in the code:
Read,Glob,Grep,NotebookReadtiers.ts'sSAFE_TOOLS, sopreClassifyreturns GREEN and rung 1 grants them free on every call. A learned green over them cannot change a decision (generalise.tsrule 1;replay.ts's INERT would reject it), and they can neither fall closed nor leave a gap, so neither lane has evidence about them at all.BashescapesCwd.write_to_filetrail.ts'spick('path')is a display fallback tried across every tool, not a claim about Bob's schema. A matcher over a guessed key matches nothing forever and reads exactly like a clean run — the failure shape this wave is hunting. Add it with a real record in hand.Three axes deliberately absent, because nothing can reach them
PATH_NEVER_WIDENhas two entries, and the test constructs a real cluster for each. The temptingthird, fourth and fifth would have been branches no honest test could trigger — the
unreachable-rule failure the process notes warn about twice:
traversal—normalisedPathrunspath.resolve, so a..is collapsed before the axis tableis consulted, and a
..that leavescwdhas no shape at all.out-of-cwd—canonicalPathSegmentreturns'', so the cluster refuses asno-matcher-shapebefore an axis is reached..env,id_rsa,credentials—tiers.ts'sDESTRUCTIVEalready matchesthose against a haystack containing
file_path, so rung 1 returns RED and the record is adeny.A
denyis neither green support (E3b) nor gap support (decision === 'none'), so it can neverreach a support set. Covered, without a rule pretending to cover it.
What is there:
corpus-path(a machine may not grant itself the corpus) anddot-root(a dotdirectory at the repository root is tooling, config, or another agent's state —
.git,.github,.claude— and a learned blanket allow over it is not something six writes should buy;src/.generatedis ordinary and passes).
Two checks deleted because mutation proved them redundant
Both were written, both looked load-bearing, neither failed a test when removed — so both are gone,
with a comment saying so:
symlinked literal is a symlinked every path and the per-path loop refuses first. Removing it
failed nothing.
isAbsolute(p)guard incommonPathLiteral. A relative path resolves against the miner's cwd,which is never under the session's root, so the
..test already refuses it.Removing the per-path loop did initially fail nothing either — because the only symlink test at
the time had the whole
infra/proddirectory symlinked, which the literal check also caught. That isa silence reading as success, so a second test was added for the case only the loop can see: a
supporting file escaping through
infra/prod/iamwhile the literalinfra/prodis genuinely clean.The loop then had exactly one test, and kept it.
The output contract is unchanged
learned/<id>.mdatstatus: proposed, throughassertWritableandwriteClause, dateless<kind>-<slug>-<shape12>id, and the status guard refusing to overwrite any file whose parsed statusis not
proposed. Asserted end to end, including that adeclinedfile suppresses re-proposal.Ledger accounting:
path-below-floorjoinsno-matcher-shapeandprefix-too-shortinsuppressed.proseOnly— the shape is real and a human could still write the rule.path-symlinkeddeliberately does not: that cluster is refused because the tree it describes is not the tree it
looks like, which is not advice to hand a human. Both directions are tested.
Numbers
origin/main(fd87f2a)make checkgreen.bash ci/check-plugin-lib.shgreen aftermake plugin, committed.eslintclean(one pre-existing warning in
SessionManager.test.ts, untouched). Zero runtime dependencies added,Node stdlib only, no
.pyanywhere.Every fixture invented; no real path or project name.
Mutation evidence
Eleven mutations, each reverting one half of the change independently, run against the committed tree
(
496policy tests as the denominator):(?=\/)→ the shell lane's(?=[\s"\\])PATH_FLOOR_SEGMENTS2 → 1segmentsOftakes the rawfile_pathdirnamecorpus-pathaxisdot-rootaxis..out-of-cwd guardpath-below-floorfromproseOnlyReviewer's shortest path
src/policy/propose.ts—pathMatcher(the boundary),commonPathLiteral(the floor and itsinverted direction),
symlinkEscape(relative positions, and why),PATH_NEVER_WIDEN(and thethree absent axes).
src/policy/generalise.ts—PATH_TOOLSandnormalisedPath, and the excluded-tool table.src/test/policy/paths.test.ts— every invariant, withinfra/prodvsinfra/production-notes/asserted against a compiled matcher over a real
haystackForstring rather than against patterntext.
docs/KNOWLEDGE.md— the textual-guard section, which is the thing File-path policy cannot be learned — Write/Edit clusters refuse as no-matcher-shape #83 asks the docs not to let areader misread.
Not done, and deliberately
infra/prodisrelative to whatever cwd the call had, so it would license the same directory name in another
repository. A relative-encoded call therefore misses the fast path and falls through to the
classifier — under-matching, which is the fail-closed direction.
haystackFor. Normalising at the runtime seam would change what every hand-writtenclause matches, for a gain the fail-closed fall-through already covers.
consumes such a setting, and the brief's own rule is that every field must have a consumer in code
or it does not ship. It is one exported constant with the argument for its value beside it.