Skip to content

Measure a relative path from the directory its own tool call ran in — and refuse the command when that cannot be proven - #180

Merged
bogatyrjov1 merged 6 commits into
mainfrom
resolve-cwd-and-invert-fence
Aug 29, 2026
Merged

Measure a relative path from the directory its own tool call ran in — and refuse the command when that cannot be proven#180
bogatyrjov1 merged 6 commits into
mainfrom
resolve-cwd-and-invert-fence

Conversation

@bogatyrjov1

@bogatyrjov1 bogatyrjov1 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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:

cd packages/api && cat src/config.ts
  shell reads   packages/api/src/config.ts
  fence measured             src/config.ts      # at the top of the repo

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.cwd field this was
expected to arrive in appears on 0 of 110,141 tool inputs), and a cd sits in the command
text 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 cd and, finding none, confidently used the record's
directory. Two rounds of adversarial review found twenty-two ways to move a shell it did
not recognise. The sharpest are not exotic:

c\d sub && cat src/config.ts      # runs `cd`. contains no "cd".
c"d" sub && …                     # same
c${EMPTY}d sub && …               # same, via expansion
source ./setup.sh && …            # the script cd's; the line says nothing about where
cd "s3"x && …                     # the target is s3x, not s3

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?

  • Detection runs over the command as the shell will see it — quoting removed, expansions
    collapsed — and is deliberately over-eager (cd/pushd/popd/chdir/source/.,
    -C/--chdir/--directory). Over-detecting costs paths; under-detecting leaks them.
  • 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.

The refusal lives in sessionPaths itself, so a consumer that supplies no escapesRepo gets
the 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 init repos, a real git worktree, real symlinks, the real runCapture with only
the wire stubbed; filePaths read off the real POST body; every escaping row given its own
colliding filename, and each one confirmed by running it under a real shell and reading the
other repository's bytes.

probe this branch v1 of this PR main (0.25.0)
24 spellings invented before the redesign 24 / 24 24 / 24 0 / 24
23 from review + verifier round 1 23 / 23 5 / 23 ~0 / 23
24 invented after the redesign 24 / 24 14 / 24 0 / 24
14 residuals from verifier round 2 14 / 14 0 / 14 0 / 14
7 from verifier round 3 (cd sub; cd nonexist, CDPATH, function bodies) 7 / 7 5 / 7 5 / 7
must-keep controls held throughout held held

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 !moved branch in admit whose two
roads both ended in a drop. Several survivors produced real tests once I found why they were
masked: a conditional cd hidden by the session cwd already being among the bases, a Codex
argv 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 declaredCache key's roots component,
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:

  • 8.1% of relative shell paths dropped as unplaceable. Mostly source/. running a
    script, plus function definitions and CDPATH. Two over-detections were removed after
    measuring them: a bare -C is an everyday flag of grep/git/tar/sort, and source
    as an argument is not a command — together they were refusing 1,800 commands for nothing.
  • ~1.2% dropped because the directory they were written in resolves outside the repository
    about seven in ten of those are the misattribution being corrected, not a loss.

Also fixed here

  • A cd to a directory that does not exist moves nothing, because a real shell's cd
    fails 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.
  • A mis-read target composed a base that was not where the command went; because an
    unresolvable base is skipped, that fell back to the session's own directory — the one place
    the command definitely was not.
  • A failed cd after a successful one (cd sub; cd nonexist) sent the token back to the
    repo root instead of leaving it in sub. The chain now crosses the boundary and the caller
    walks back to the deepest link that exists.
  • The declared directory is authoritative: appending the session cwd and roots to it put
    the guess back and dropped paths that were honest where the command actually stood.
  • 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.
  • @backthread/redact bumped to 0.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 cd in one would govern the
next. 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-prs

Decisions

  • Rejected replacing the containment check with a git-backed allowlist (send a path only if git ls-files vouches 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-candidate git ls-files --error-unmatch still 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.
  • Inverted the cwd determination instead, which is where the open-ended question actually was. The containment walk was already a closed allowlist of physical destinations; it was being handed the wrong starting point.
  • Ran detection over the command after quote removal and expansion collapsing rather than over the text as written. This was forced by measurement, not taste: c\d, c"d" and c${EMPTY}d contain no cd and run one.
  • A cd target that does not resolve is skipped, because a real shell's cd fails 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

  • Assumes a record's stamped cwd is 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.
  • Assumes each command starts from its own stamped directory; measured true in 96.1% of 4,055 decisive real cases. If an agent's shell does persist, a cd in one command would govern the next and could leak.

Trade-offs

  • Accepted 8.1% of relative shell paths dropped as unplaceable, up from 2.5% before source, quote-aware detection, CDPATH and function definitions were added. The alternative — trusting the reader when it finds no literal cd — is what produced twenty-two confident wrong answers.
  • Removed two guards no test could turn red rather than writing tests that only appeared to cover them. Kept one documented survivor, because removing it would trade a correctness property for a mutation score.

Limitations

  • Not a proof. A shell can name a command in more ways than a text scan can enumerate; the claim is bounded to the constructs found across four adversarial rounds plus the two hiding mechanisms. Check: re-run an adversarial enumeration before widening any claim about this.
  • A path written from a subdirectory is still recorded under the spelling used, so a decision can attach to the wrong file inside the same repository.
  • The recall measurements come from one machine's transcripts, where sibling worktrees are the normal workflow. Check: re-measure before quoting 6.8% or 1.2% as general figures.

↳ generated by backthread/add-reasoning-to-prs · an open-source Claude Code hook · edit or delete freely

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.
@bogatyrjov1 bogatyrjov1 changed the title Measure a relative path from the directory its own tool call ran in Measure a relative path from the directory its own tool call ran in — and refuse the command when that cannot be proven Aug 29, 2026
… 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.
@bogatyrjov1
bogatyrjov1 merged commit 3a21d79 into main Aug 29, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants