Measure a relative path from the directory its own tool call ran in — and refuse the command when that cannot be proven - #180
Merged
Conversation
A relative path means something different in every directory, and the fence only knew the directory the SESSION started in. When a command moved -- `cd packages/api && cat src/config.ts` -- the shell read one file and the fence measured another. Where the directory it moved into is a symlink out, or is another repository outright, that is another project's file recorded under this one's name. Two signals were on hand and unread: the agent stamps a working directory on every record, and a `cd` is in the text already being scanned. Both now travel with the candidate, so the fence and the filesystem start from the same place. A `cd` that cannot be read makes the reading more conservative, not less; a `cd` to a directory that is not there moves nothing, because a real shell does not move either. 24 newly-invented spellings refused, each first confirmed with cat to open the other repository; the same probe leaks all 24 on 0.25.0. Costs 1.2% of recorded relative paths, ~70% of which is the misattribution being corrected.
… guessing
Reading a shell command to find out where it moved is an open question.
The first version of this looked for `cd` and, finding none, confidently
used the record's directory. Review found eleven ways to move a shell it
did not recognise -- pushd, cd -P, cd --, { cd x; }, \cd, env -C, then cd,
time cd, bash -c 'cd x', cd -, and a Codex argv array hiding the cd behind
a flag -- each producing a confident wrong answer. That is one defect, not
eleven, and it is the same shape the path fence kept losing to before it
started asking the filesystem.
So the question asked is now the closed one: can we prove the command did
NOT move? Anything that might change a directory is detected crudely and
over-eagerly, and the reader must account for every detection; whatever it
cannot explain makes the command unplaceable, and an unplaceable relative
path is dropped rather than attributed to a directory nobody can name.
24 spellings invented before the redesign refused, 24 more invented after
it refused with no further change -- the first round here where a fresh
search found nothing new. Costs 2.5% of relative shell tokens as
unplaceable, plus ~1.2% dropped for resolving outside the repo.
Also: a base must resolve on disk, so a cd to a directory that is not there
moves nothing; an out-of-repo directory contradicts only when a command
moved to it, never when the session merely sat in it.
… it is spelled
Adversarial review of the previous commit found fifteen more escapes, in
three shapes.
The sharpest: `c\d`, `c"d"` and `c'd'` contain no `cd` for any scan to
find and run one anyway, because the shell strips quoting before it looks
the command word up. `c${EMPTY}d` does the same through expansion. So
detection now runs over the command as the SHELL will see it -- quoting
removed, expansions collapsed -- and a construct that appears only after
that reduction refuses the command. `source`/`.` join the detected set:
a script can cd anywhere and the line that runs it says nothing about
where.
Second: a target read as a PREFIX composed a base that was not where the
command went (`cd "s3"x` is `s3x`), and an unresolvable base is skipped,
which falls back to the session's own directory -- the one place the
command definitely was not. A target carrying quoting or redirection now
refuses the command, and residue after the match does too.
Third: a record working inside a DIFFERENT repository had its directory
discarded and our own root then vouched for the path. An out-of-repo
directory now contradicts whether or not a command moved to it; `moved`
is spent where it decides something -- whether a base that will not
resolve means the cd failed or that nothing can be placed.
85 spellings across four adversarial rounds, each cat-proven under a real
shell; all refused. Costs 6.8% of relative shell tokens as unplaceable,
mostly `source`. This is a stronger fence, not a proof -- a shell can name
a command in more ways than a scan can enumerate.
One comment still quoted the 2.5% unplaceable rate from before source/. and the quote-removal detection widened it to 6.8%, and the corpus size appeared as both 73,406 and 73,800. A stale number in a comment that exists to state a trade honestly is worse than no number.
…worked A third adversarial review found the sharpest case yet: 'cd sub; cd nonexist' leaves a real shell standing in sub, because a cd to somewhere that is not there fails and moves nothing. Composing only the final spelling resolved to nothing, and reading nothing as no-opinion handed the token back to the repo root -- so a path read from sub was recorded as though it had been read from the top of the repo, and where sub holds a link out, that is another repository's file under this one's name. So the chain crosses the boundary rather than one answer, and the caller walks back up it to the deepest link that exists. That is what a failed cd actually does. 'exact' replaces 'moved': it says whether the chain is the sequence the shell walked or only the places it might have been, which is the one thing the distinction ever decided. Also from that round: CDPATH redirects a perfectly readable cd, and a function body runs where it is called rather than where it is written -- both now refuse the command. And the declared directory is authoritative: appending the session cwd and roots to it put the guess back, dropping paths that were honest where the command actually stood. Two over-detections removed after measuring what they cost: a bare -C is an everyday flag of grep, git, tar and sort, and 'source' as an argument is not a command. 1,800 commands were being refused for neither. 91 spellings across five rounds, each first run under a real shell and only scored where it genuinely reads the other repository. Unplaceable cost 6.8% -> 8.1%.
…fix was refused Two things a reader would otherwise have to infer. First: the criterion this inherits is stated absolutely -- no path belonging to another repository reaches the wire -- and this change does not establish it. The residual is a directory change whose command word cannot be recovered from the text at all: an alias, or an expansion whose value is not in the transcript. Detecting that from text is undecidable against a shell, which is a property of the problem rather than of how hard anyone looked. Second: dropping every relative path scraped from a shell command WOULD close it completely, and it was refused. That route contributes about 84% of the paths shell commands yield, and harvesting them is what moved anchoring from 5.3% to 93.0% on a measured machine. A completeness claim bought with the capability the product exists to provide is the wrong trade. Written down so it is not re-proposed later as an obvious win.
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.
Measure a relative path from the directory its own tool call ran in — and refuse the command when that cannot be proven
A relative path means something different in every directory. The containment fence only ever
knew the directory the session started in, so when a command moved, the shell read one file
and the fence measured another:
Where the directory moved into is a link out of the repo — or is another repository outright
(
cd ../other-project) — that is another project's file recorded under this one's name.Two signals were on hand and unread: the agent stamps a working directory on every record
(present on 73,281 of 73,281 shell tool-use records, while the
input.cwdfield this wasexpected to arrive in appears on 0 of 110,141 tool inputs), and a
cdsits in the commandtext already being scanned (71.9% of ~73,800 real commands contain one).
The load-bearing part: knowing when not to trust that reading
The first version of this PR looked for
cdand, finding none, confidently used the record'sdirectory. Two rounds of adversarial review found twenty-two ways to move a shell it did
not recognise. The sharpest are not exotic:
The shell strips quoting and performs expansion before it looks the command word up, so a
scan of the text as written is reading something that never runs. That is not twenty-two bugs;
it is one, and it is the same shape the path fence kept losing to before it started asking the
filesystem.
So the question asked is now the closed one: can we prove the command did not move?
collapsed — and is deliberately over-eager (
cd/pushd/popd/chdir/source/.,-C/--chdir/--directory). Over-detecting costs paths; under-detecting leaks them.unplaceable, and an unplaceable relative path is dropped rather than attributed to a
directory nobody can name.
The refusal lives in
sessionPathsitself, so a consumer that supplies noescapesRepogetsthe same answer — the absence of a check must never be why something leaks.
C1 is NOT claimed, and this PR does not close it
The criterion this line of work inherits is stated absolutely: no path belonging to another
repository can reach the wire. This change does not establish that, and does not claim to.
Detecting whether a command changed directory, from the command's text, is undecidable against
a shell — quoting, expansion and aliasing can spell a command word in unbounded ways. That is a
property of the problem, not of how hard anyone looked, so further adversarial rounds narrow a
residual that cannot reach zero. The residual class: a directory change whose command word
cannot be recovered from the text at all — an alias, or an expansion whose value is not in the
transcript. Measured cost of what is closed: 8.1% of relative shell paths dropped as
unplaceable, plus ~1.2% dropped for resolving outside the repository.
C1 stays open. Neither this PR nor the issue it inherits from should be read as closing it.
The absolute version was considered and rejected — recorded so it is not re-proposed
Dropping every relative path scraped from a shell command would close the class completely.
It would also discard about 84% of the file paths shell commands contribute, and those are
the reason a decision can be anchored to code at all: on one measured machine, harvesting them
moved the share of decisions anchored to real files from 5.3% to 93.0%. Buying a
completeness claim with the capability the product exists to provide is the wrong trade.
Evidence
Real
git initrepos, a realgit worktree, real symlinks, the realrunCapturewith onlythe wire stubbed;
filePathsread off the real POST body; every escaping row given its owncolliding filename, and each one confirmed by running it under a real shell and reading the
other repository's bytes.
main(0.25.0)cd sub; cd nonexist,CDPATH, function bodies)91 spellings, all refused, each first run under a real shell and scored only where it genuinely reads the other repository. Round 3's honest-path controls also went from 2/7 held to 7/7.
Non-regression, re-proven: sibling-worktree path IN; different repo OUT; every 0.24.0 and
0.25.0 spelling still closed (the existing 28-spelling table is unchanged and green); the
deliberate laundering trade-off (one root saying "outside" is enough) untouched.
Mutation battery: 26/27 killed. Guards no test could turn red were removed rather than
given a fake test — a duplicate caller-side check, and a
!movedbranch inadmitwhose tworoads both ended in a drop. Several survivors produced real tests once I found why they were
masked: a conditional
cdhidden by the session cwd already being among the bases, a Codexargv row whose name another record also supplied, a phantom base that happened to land inside
the repo, and a memo pairing that needed the same spelling in both records with the refusal
evaluated first. One documented survivor remains: the
declaredCachekey'srootscomponent,which cannot vary within a capture and is kept for symmetry with its two sibling caches.
Full suite: 950 + 363 + 2201 green. No existing test replaced.
Cost, stated rather than inherited
Measured over ~73,800 real shell commands:
source/.running ascript, plus function definitions and
CDPATH. Two over-detections were removed aftermeasuring them: a bare
-Cis an everyday flag ofgrep/git/tar/sort, andsourceas an argument is not a command — together they were refusing 1,800 commands for nothing.
about seven in ten of those are the misattribution being corrected, not a loss.
Also fixed here
cdto a directory that does not exist moves nothing, because a real shell'scdfails and stays put. v1 walked through the absent segment and invented a phantom directory,
which drops honest paths when the phantom lands outside the repo.
unresolvable base is skipped, that fell back to the session's own directory — the one place
the command definitely was not.
cdafter a successful one (cd sub; cd nonexist) sent the token back to therepo root instead of leaving it in
sub. The chain now crosses the boundary and the callerwalks back to the deepest link that exists.
the guess back and dropped paths that were honest where the command actually stood.
root then vouched for the path. An out-of-repo directory now contradicts whether or not a
command moved to it.
@backthread/redactbumped to0.1.6— it had changed twice without one.Deliberately not fixed
A path that stays inside the repository but was written from a subdirectory is still recorded
under the spelling used, not the repository-relative name. Nothing foreign escapes through it;
the file named may be the wrong one. It is an anchoring problem rather than a containment one
and wants its own change. Deleted files still lose their paths, unchanged.
If an agent's shell kept its working directory between commands, a
cdin one would govern thenext. Measured across 4,055 real cases where the two readings disagree, the shell had
reset in 96.1%, so each command is read from its own stamped directory.
Reasoning
written by the agent in-session via
backthread/add-reasoning-to-prsDecisions
git ls-filesvouches for it). Measured on a fixture where each escaping spelling reduces to a genuinely tracked name: the batched form admits every collision case, and per-candidategit ls-files --error-unmatchstill admits the..-through-symlink escape 0.25.0 closed, because git cancels..lexically too. Git's index answers which names a repo has; every escape in this family is about where a spelling lands. It also costs 5.36 ms per candidate and drops untracked-new and staged-delete files.c\d,c"d"andc${EMPTY}dcontain nocdand run one.cdtarget that does not resolve is skipped, because a real shell'scdfails and does not move. A target we cannot reduce the way the shell does refuses the command instead — those are different cases and were previously conflated.Assumptions
cwdis the directory that record's tool call ran in. If an agent stamps the session directory instead, relative paths from subdirectories are measured from the wrong place and dropped rather than leaked. Check: confirm on a non-Claude-Code agent before relying on the recall figures.cdin one command would govern the next and could leak.Trade-offs
source, quote-aware detection,CDPATHand function definitions were added. The alternative — trusting the reader when it finds no literalcd— is what produced twenty-two confident wrong answers.Limitations
↳ generated by
backthread/add-reasoning-to-prs· an open-source Claude Code hook · edit or delete freely