{{ message }}
Repository navigation
Tags: backthread/backthread
Tags
Measure a relative path from the directory its own tool call ran in (#… …180) A relative path means something different in every directory, and the fence only knew the directory the session started in. When a command moved, the shell read one file and the fence measured another; where it moved into a link out of the repo, or into another repository, that is another project's file recorded under this one's name. The agent stamps a working directory on every record and a cd sits in the command text already being scanned. Both now travel with the path. The load-bearing half is knowing when NOT to trust that reading. Three rounds of adversarial review found twenty-five ways to move a shell that a literal search for "cd" does not see -- pushd, cd -P, { cd x; }, env -C, bash -c, cd -, source, c\d, c"d", c${EMPTY}d, CDPATH, a function body that runs at its call site, and a failed cd after a successful one. So detection now runs over the command as the SHELL will see it, with quoting removed and expansions collapsed, and anything the reader cannot account for makes the command unplaceable: its relative paths are dropped rather than attributed to a directory nobody can name. 91 spellings across five probe rounds, each first run under a real shell and scored only where it genuinely reads the other repository. All refused; the same probes leak them on 0.25.0. Everything closed by 0.24.0 and 0.25.0 stays closed and the laundering trade-off is intact. C1 IS NOT CLAIMED. Deciding whether a command changed directory from its text is undecidable against a shell, so the residual -- an alias, or an expansion whose value is not in the transcript -- cannot reach zero. The absolute fix, dropping every relative shell path, was refused: it discards ~84% of the paths shell commands contribute, and harvesting those is what moved anchoring from 5.3% to 93.0% on a measured machine. Cost: 8.1% of relative shell paths dropped as unplaceable, plus ~1.2% for resolving outside the repo. 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 -- recovering ~1,800 commands.
Resolve a path against the filesystem before normalizing it (#179) * Resolve a harvested path against the filesystem before normalizing it The fence resolved the path it was about to EMIT. Two mechanisms walk straight through that, and they are one shape: the filesystem was asked a question whose answer had already been thrown away, or was not asked at all. `..` CANCELLED ON THE STRING. `dlink/../src/secret.ts`, where `dlink` is a link into another repository, reduces arithmetically to `src/secret.ts` -- a name this repo genuinely has -- while the kernel opens the other repo's file. Cancelling `..` is only valid against a real directory; against a symlink it is simply wrong, because the link does not go where its name says. By the time the escape predicate was called the `..` was gone, so no predicate at that position could have seen it, however it was written. A SEGMENT THE FILESYSTEM WILL NOT RESOLVE, CLIMBED PAST. Resolving the deepest ancestor that exists treats "cannot say" as "keep looking upward", lands on an ancestor that IS inside the repo, and keeps the path. The note left in the code last time named a dangling link as the one case. It is not one case: an escaping link behind a chmod-000 directory does it with EACCES, and a symlink loop does it with ELOOP. The class is anything that makes the filesystem unable to answer at the point the fence asks, and it is written for the reasons nobody has measured yet, not the two that have been. So the raw spelling now crosses the package boundary -- absolute or relative, `..` and `./` and either separator intact -- and the caller walks it through the filesystem one segment at a time. Everything to the left of a `..` has already been followed on disk when the `..` is reached, so its parent is the true one. Only a positive ENOENT continues the walk, and then only on ground we know is not a link, which is what keeps the deleted file's path. Everything else fails closed. That is a second signature change to a published interface, and it is the point rather than the cost: a half-resolved path crossing that boundary IS the defect. The leaf is still not followed. What leaks is a path descending THROUGH a link into somebody else's directory structure; a symlinked file at a name this repo really has is our own metadata, and git tracks it. Two shapes are refused before any of that. A URI scheme, because `file://...` is not POSIX-absolute and so was treated as relative and emitted as `file:/...` -- a machine-absolute path, out of a module whose stated contract is that it never emits one. And a percent-escaped separator, which is two different paths depending on who decodes it, so the fence would measure one and the kernel would open the other. That check sits beside the NUL refusal rather than in the relative branch, because an absolute spelling never reaches the relative branch and `<root>/vendor%2Fsrc/secret.ts` was getting through on exactly that. Measured on real git repos, real symlinks and real files, with `cat` confirming that each spelling genuinely opens the other repository's file: 23 re-spellings of the escape, every one refused, and the four properties that had to survive it -- our own file, a sibling worktree's real file, a file the session deleted, and our own name for a file that is a link out -- all still reaching the wire. The same probe run against the previous release leaks 21 of them. The `..` cases needed a fixture nobody had built: a link that stays INSIDE the repo, with the `..` after it doing the leaving. Every earlier fixture had the link itself land outside, so a mutant that skipped `..` altogether survived the whole end-to-end battery until that case existed. * Pin the trade-off that comes with resolving a place instead of judging a name The previous release dropped a path as soon as ANY checkout said that name leaves the repo. That inversion is what closes the laundering case -- roots are checkouts of one repo, so every tracked directory exists in all of them, and a rule that keeps a path as soon as SOME root resolves it inside lets a sibling worktree vouch for an escaping link at a name the repo genuinely has. It also had a cost nobody had measured: an honest file living at that same name in a sibling worktree went down with the escape. Resolving the candidate rather than judging its name splits that in two, and the split is worth writing down because either half alone reads as a bug. A RELATIVE spelling is a name and nothing more, so the inversion still applies and the honest file is still lost. An ABSOLUTE spelling is a place: it can be followed on its own, no other checkout gets a vote, and the honest file comes back. Measured: it is dropped on the previous release and kept here, while the escape through the very same name is refused in both spellings. One end-to-end case, on real repos and real links, carrying all four legs at once so nobody can "fix" one of them and quietly reopen the other. The changelog says the same thing to the reader, along with the three limits that are still true rather than fixed: the shell harvest still requires the file to exist, an already-relative path with no resolvable root is still kept as-is, and a path deeper than the walk cap is dropped rather than measured. * A backslash in a FILENAME was the same escape wearing a different costume Independent verification of the previous commit found a spelling it does not close, and the mechanism is the one this whole change exists to remove: the fence measuring a different path from the one the kernel opens. This module read `\` as a second path separator, so that a Windows spelling could be reduced like a POSIX one. On POSIX it is not a separator -- it is an ordinary character in a filename. So a symlink genuinely NAMED `x\y`, pointing at another repository, was split here into the segments `x` and `y`, neither of which exists; the walk found nothing on disk, read that as empty ground inside the repo, and kept the path. The kernel resolves `x\y` in one hop into the other repository. Proven by reading the file through that spelling: it returns the other repo's bytes, and the path reached the wire as `x/y/src/secret.ts` -- a path this repo does not have. Present on the previous release too, in the absolute spelling and with a `..` in it as well. There is no reading of `\` that is correct on both platforms, and nothing here can know which one produced the string, so the ambiguity is refused rather than guessed at -- the same answer already given to a percent-escaped separator. A candidate carrying a backslash is dropped at the fence, and `/` is the only separator anywhere below that line. The two `\`-aware splits are gone with it: having two answers to "what is a separator" in one module is how this happened, and the remaining mutants against them are equivalent precisely because nothing carrying a `\` can reach them any more. The cost is stated in the changelog rather than left to be found: a Windows RELATIVE spelling (`src\thing.ts`) is now dropped. Windows absolute spellings were already being dropped before this. Two more from the same round. AN UNRESOLVABLE ROOT COULD VOUCH. The resolved-root set drops a root that no longer resolves -- it cannot confirm anything, and a logical spelling kept beside physical ones confirms by prefix instead. The code said so in a comment and nothing could fail on it. Now two roots, one live and one gone, with the live root's own file asserted alongside so a green cannot mean nothing was sent. AND THE NUL REFUSAL IS NOW EVERY C0 CONTROL CHARACTER. A transcript-supplied `file_path` containing a newline was reaching the wire as a "path" that renders as two lines wherever it is later stored, joined or shown. Also removes a raw NUL byte that had been committed into the source as a cache-key separator: it made grep treat the file as binary and silently return nothing, which is a poor thing for the one line deciding containment. The key does not need it -- `absolute` is a pure function of `raw`, so the two kinds could never have collided. * Kill the last two survivors, and stop two comments claiming more than they hold A second independent round put 32 mutants through the fence. Five survived; three were provably equivalent and two were real coverage gaps. Both are now dead. A TRAILING `..` IS NOT A LEAF. The walk deliberately does not follow the last segment of a path -- that is what keeps our own name for a file that happens to be a link out -- but a spelling ending in `..` has no leaf to skip, and popping it walks one segment DEEPER than the spelling names. `sub/rootlink/..`, where `sub/rootlink` points back at the checkout root, then stops at the root and reads as inside the repo, when what it actually names is the directory the repo sits in. Every other spelling in the suite ends in a filename, so nothing had ever reached that branch. AN UNRESOLVABLE ROOT MUST BE STEPPED OVER, NOT READ AS A VERDICT. The relative route asks every root in turn and stops at the first contradiction. A root that fails to resolve answering "no escape" means the first dead root in the list acquits every path behind it, before any root that could contradict is consulted. Reached with an injected root set: the real resolver verifies every root it returns, which is what keeps this defensive rather than live, and also why nothing could fail on it. Two comments were also overstating their subject, which the same round measured. The walk's `/`-only split and the backslash refusal are MUTUALLY redundant, and the comment implied a one-way dependency. Measured: either alone closes the `x\y` escape -- the `/`-only split because it makes the walk measure the path the kernel opens, the refusal because the candidate never arrives. Only removing BOTH puts it back on the wire. So a mutation of either line alone will not go red, and now the next reader knows that is redundancy rather than a missing test. And the control-character refusal is not quite "refuses every control character": a path-named tool input is trimmed before it arrives, so a tab or a newline at either END is removed rather than refused, and the trimmed path is kept. Nothing carrying a control character is emitted either way, which is the property that matters, but the comment now says which case is which instead of implying one rule. * Release 0.25.0: resolve before normalizing, and refuse what cannot be measured The four lockstep-checked version declarations, the rebuilt bundle, and the lockfile -- which the bump helper does not touch, and which would have failed `npm ci` on a version it no longer matches. * The input trim was erasing the first segment, and the first segment is the fence An independent review of this branch found one more spelling that escapes, and it is the third costume of the same defect: the fence measuring a different path from the one the kernel opens. A path-named tool input was pushed as `v.trim()`. Trimming touches the ENDS of a string, and the FIRST SEGMENT of a path is an end. So a symlink named ` vendor` -- leading space -- pointing at another repository arrived at the fence spelled `vendor`, which this repo does not have; the walk found nothing there, read that as empty ground inside the repo, and kept the path. The kernel, given the spelling the agent actually used, opened the other repository in one hop. Reproduced independently before fixing: the file really opens, and `spacevendor/src/x9.ts` reached the wire. A non-breaking space works identically, because `trim` strips every Unicode whitespace character, so this was not one spelling but a family. Only the tail is trimmed now. That half is safe and worth keeping: the walk never follows the last segment, so nothing the tail does can move where a path resolves, and a trailing newline in a tool-input field is common enough that refusing it would cost real paths. A LEADING control character is no longer trimmed away either -- it now reaches `CONTROL_CHARACTER` and is refused, which is the better answer than silently repairing it. The rest of the review was comments claiming more than the code does, which on this file is not cosmetic -- every one of these bugs was survivable because a comment said the line was already handled. - `realPathImpl`'s contract still said "null when it names nothing on disk". That is precisely the belief this change exists to destroy, and it is what a test author reads before writing a stub. A stub built to it returns null only for absent paths and silently stops exercising the dangling, EACCES and ELOOP cases that `isAbsentImpl` was added to separate. - The memo comment carried both its old and new rationale stacked, so it read as if the key still included the candidate's kind. - The "one root saying outside is enough" paragraph appeared twice, verbatim, nine lines apart in one function. - Three more pointed at deleted lines: a backslash check that moved to the loop, "either separator" on a field that can no longer carry one, and an inline comment describing only NUL above a check that now covers every C0 character. `isAbsent` is memoised now, like `realOf` already was. The relative route walks every root, so a name present in only one of them asked the filesystem once per root per path -- and on a machine running parallel worktrees that is the ordinary case, not the rare one. Two accepted costs go in the changelog rather than staying folklore: a file of ours behind an unreadable directory or a symlink loop is dropped (the same rule that refuses an escaping link hiding there, which cannot tell them apart), and the path we EMIT is still reduced arithmetically, so an in-repo `..` can name a file that was never opened. Neither can cross a repo boundary. The second is worth fixing properly -- the walk already knows the physical destination -- but that is another change, not a line in this one. * A relative path is resolved from the session's cwd, and the fence walked from the roots Third independent round, fourth escape, same shape as the other three: the fence measuring a different path from the one the kernel opens. A RELATIVE candidate was walked from the repo ROOTS. The kernel resolves it from the directory the session is running in. Those are the same place only when the session sits at the top of the checkout -- and an agent working inside a monorepo package does not. With the session in `<repo>/sub` and `sub/dlink` a link to another repository, `dlink/src/secret.ts` was resolved from the root to `<repo>/dlink/...`, which does not exist, which the walk reads as empty ground INSIDE the repo, so the path was kept. Resolved the way the kernel actually would, it opens the other repository. Reproduced before fixing, with `cat`: the file really opens, and `dlink/src/secret.ts` reached the wire under this repo's name. The same missing base defeated `exists`, and that form is worse. `exists` also joins against the roots, so with `pkg/src` linked out and the session in `<repo>/pkg`, the shell token `src/keep.ts` names the OTHER repo's file while this repo has its own `src/keep.ts` -- existence confirmed it, and it shipped spelled as one of ours. Nothing downstream could ever have told the two apart. Both forms close together, because both were one missing base. The session cwd is now a base a relative spelling is measured from, ahead of the roots, and only when it resolves INSIDE the repo -- a cwd that resolves elsewhere is not something this repo's paths can be measured from, and admitting it would contradict everything and empty the harvest rather than protect it. The "any base saying outside is enough" rule is unchanged; this adds a base to it rather than changing the question. The test carries both forms and one more thing: a genuinely repo-relative path, reported from that same subdirectory, still survives. Without that leg a fix that simply refused everything relative would pass, and it would have been wrong. This was not a regression. The previous release leaks the identical rows; this change had not closed it. Present in the changelog as the one most likely to have affected a real repository, because working inside a package directory with a symlink in it is an ordinary way to work, not a contrived one. `resolveWalk`'s root argument also stops being called `realRoot` in the loop -- the bases are resolved once now, in one place, and a root that does not resolve is skipped there rather than inside the walk. * Pin the memo key, and say where the fence still does not hold A fourth verification round found one real coverage gap and one more way through. The gap is closed here. The way through is not, and this commit is mostly about saying so precisely rather than letting the changelog imply otherwise. THE MEMO KEY. Containment is memoised on the whole raw spelling. The obvious cheaper key is the parent directory -- it is what the previous release used, and paths really do cluster into a few directories -- and it is order-dependently unsafe. `walkableSegments` strips `.` segments BEFORE popping the leaf, so `sub/dlink/.` walks to `sub` while `sub/dlink/x.ts` walks to `sub/dlink`: two spellings that share a parent walk to different places, one inside the repo and one through a link into another. Under a parent key whichever arrives first decides both, so the ORDER of two tool calls decides whether another repository's file ships. The shipped key was already right; nothing pinned it, so a plausible optimisation back would have reopened a cross-repo leak with the suite green. The comment above it argued only that an absolute and a relative spelling cannot collide -- true, and not the collision that matters. The comment on `walkableSegments` was wrong in the same place, which is what made that mutant reachable: it said a trailing `.` is preserved "like `..`". It is not. `.` is filtered out before the pop; only `..` survives to be walked. WHAT IS STILL OPEN, AND IS NOT A REGRESSION. The bases a relative spelling is measured from are the SESSION's cwd and the roots. The kernel resolves it from the working directory of the TOOL CALL, and those differ in three measured ways: a `cd` inside the very command string being scanned, a shell tool input carrying its own `cwd` field (which the harvester reads as a path CANDIDATE and never as a base), and a Codex transcript stating one in `session_meta.payload.cwd` (used as a root fallback, never as a base). With the session at the repo top and a link at `sub/zlink`, `cd sub && cat zlink/src/secret.ts` still records the other checkout's path. The identical spellings get through on the previous release, so nothing here made them worse -- and the comment that claimed this family was closed now says which half is closed and which is not. Closing it means carrying the originating directory alongside every candidate, a third change to the shape crossing the `sessionPaths` boundary. That is its own release. The changelog says the same thing to the reader, in the same place 0.24.0 said it about the `..` hole this release went on to close. --------- Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>
Harden the session-path fence: symlink escape, dropped-path visibilit… …y, NUL bytes (#178) * A symlink out of the checkout was carrying another repository's paths in Containment was decided by string prefix, and a string prefix does not survive a symlink. With a link like `vendor -> ../other-repo` inside a checkout, a file belonging to the other repository reads as in-repo by every rule the harvest can apply, relativizes against this repo's root, and is sent as one of its paths. The pure fence cannot see that -- only the filesystem knows where a link goes -- so it gains an `escapesRepo` seam alongside `exists`, and the CLI, which already holds a filesystem and the roots, answers it by resolving the path and asking where it came out. Any root that resolves it inside settles it, which keeps a file that lives in only one of several worktrees. A contradiction -- it resolved, and every resolution landed outside -- is what drops it. Resolving nowhere is neither: a file the session deleted is gone from disk, and requiring existence here would quietly impose the shell-token rule on path-named tool inputs, which have never carried it. Also refuses a path carrying a NUL byte. The syscall layer stops reading at it, so such a path names nothing on any filesystem this runs on, and it was metadata we went on to store, join and render. * Capture had one thing to say and was saying it down a pipe to nowhere Past 64 linked worktrees the root resolver stops checking, and the paths in the rest go back to being dropped as foreign. It announced that on stderr -- and the shipped hook re-spawns its worker with stdio ignored, so in the delivery mode everybody actually runs, the announcement reached nobody. Sixty-four is not a number most machines pass, but the ones that do are precisely the ones running many worktrees, which is the workflow the root set exists to serve. The notice now goes to a file in the config directory, written synchronously because a detached process can exit before an awaited write lands, and `backthread doctor` reads it -- the surface somebody goes to when they notice less is being captured than they expected. Both channels are used, since the terminal is the right one for a capture run by hand. Nothing clears the notice and nothing can: a live condition is re-recorded on the next capture, and one that stopped ages out of the window on its own. The sentence itself changed twice over. It reported the constant ("more than 64") where the reader needs their own numbers, and it ended by telling them to run `git worktree prune` -- which drops stale registrations, shows no skipped path, recovers none, and on a machine whose worktrees are all live does nothing at all. Following it produced no visible change and taught nothing. It now says how many were listed, how many were checked, how many were left out, and that which ones is git's listing order rather than anything about the session. There is no command that fixes this, so it prescribes none. * Release 0.24.0: follow the directory, not the file, and raise the floor Three corrections from an independent read of the fence work. The escape check followed the whole path, which dropped more than it meant to. A symlinked FILE at a name this repo really has -- src/linked.ts, tracked by git, pointing anywhere at all -- was refused along with the leak, and that is our own metadata lost to a rule aimed at somebody else's. What leaks is a path DESCENDING THROUGH a link: only 'vendor' exists here, so 'vendor/src/secret.ts' is another repository's directory structure wearing our name. So the question asked is now where the containing DIRECTORY comes out. It admits our own leaf names and still refuses every path whose parent chain leaves. The end-to-end case pins both halves against real repos and real links, and the memo key moves to the parent, which is also where the cache hits are. The committed bundle had been built against a stale install and was carrying a DOWNGRADE of an inlined dependency alongside the fix -- reinstalling from the lockfile and rebuilding leaves a diff that touches nothing but this change. That would have failed the bundle-sync gate at exactly the moment it is irreversible. The test floor had 48 tests of slack, which is a ratchet that has stopped ratcheting. * Stop the suite writing to the developer's home, and pin two boundaries Two tests run the REAL capture pipeline against a path that exists on the author's laptop, with no config-dir override and no stub for root resolution. They therefore shelled out to real git in a real checkout: what they did depended on how many worktrees that directory happened to have, and once capture gained something to say about worktrees they began writing a content-bearing file into the developer's home directory as a side effect. Both now pin the root set -- these tests are about the redaction fence, not worktree discovery -- and point the config dir at a temp dir. Proved by redirecting HOME and listing what the suite writes there. Two guards were thin enough to admit real mutants. The over-cap notice was tested at one worktree and at five hundred, which left the boundary open: the condition could fire at exactly the cap, announcing that zero worktrees had been left out. A line that appears on a machine that lost nothing is how people learn to ignore the line. Now pinned at 63, 64 and 65. And nothing covered a directory reached through a symlinked PARENT, so the escape check could compare a resolved path against unresolved roots with every test still green. The case that catches it has no git in it at all: the root set is then the session cwd alone, carrying the symlinked spelling with no physical counterpart, so the comparison matches nothing and every path reads as having left a repo that does not exist. A git checkout behind the same kind of link is covered too, since that is a property the previous release is supposed to have bought. * The fence had two more ways through, and one guard could not fail on the platform it protects An independent review broke the first version twice. A SIBLING WORKTREE COULD VOUCH FOR AN ESCAPING LINK. Keeping a path as soon as some root resolved it inside looked like the safe reading and was the opposite: roots are checkouts of one repo, so every tracked directory exists in all of them. Put the link at a name the repo genuinely has -- the ordinary trick of pointing packages/foo at a local checkout of another project -- and the sibling, telling the truth about its own directory, launders the escape. It is never asked about the one the path came from. The question is now inverted: any single root saying the path left is enough to drop it. A LINK INTO A MISSING DIRECTORY STILL LEAKED. Asking only about the immediate parent made the answer depend on what happened to sit on the far side: vendor/src/secret.ts was refused because vendor/src resolved and landed outside, while vendor/gone/secret.ts was kept, because vendor/gone resolved nowhere and nowhere is an absence rather than a contradiction. Same link, same escape, opposite verdict. We now climb to the deepest ancestor that exists, which reaches the link itself. The deleted-file property is re-asserted inside that same case, because the climb is exactly the change that could have taken it away. THE CONTAINMENT COMPARISON WAS SPELLED WITH A LITERAL SLASH. realpath and join both answer in the platform separator, so on Windows a root is one spelling and everything below it is another, and every path would have read as an escape -- not a crash, a silent empty harvest. No test on this machine can tell sep from a slash, and the end-to-end case duly passed while the mutant lived. The comparison is now a named function with the separator injected, so the win32 spelling is exercised directly. And prunable worktree registrations are skipped before the cap. Git still lists a registration whose directory was deleted, so stale entries were spending capped verification slots -- pushing LIVE worktrees past the limit -- and inflating the count reported to the reader into a count of registrations. That was the one case where the advice this release removed was right, so it is now done automatically rather than asked for, which is what makes the new message true when it says no command fixes this. * Guard three claims that were written down and defended by nothing A verification round mutated the comments rather than the code, and found three sentences this change asserts with no test behind them. The escape check is documented as receiving the roots the harvest ACTUALLY used. That matters because the harvest falls back to a root it finds in the transcript when the caller supplies none, and a predicate closed over the caller's empty list is measuring against no directories at all -- so it can only ever answer that nothing left the repo, which is the second weaker route the documentation promises does not exist. Passing the caller's list instead left the suite green. And both doctor lines. An absent notice is also what a machine looks like when the hook has never fired, is misconfigured, or cannot write to the config directory, so the wording has to report the state of the mailbox and claim nothing about a capture that may never have happened. The file says so in a comment; the comment could be replaced with the exact sentence it forbids and nothing failed. * Say where the fence still does not hold, in the release that tightens it Independent verification measured 23 spellings of the escape that leak in 0.23.0 and are refused here, and six that still get through unchanged. None is a regression and none is fixed by this release. A reader who finishes the changelog believing containment is now complete would be worse off than before, so the limitation is stated beside the fix rather than left to be discovered. All six need a `..` segment or a URI scheme in a path handed to a file-reading tool, and they share one cause: `..` is cancelled arithmetically on the string before anything consults the filesystem, and cancelling `..` against a symlinked directory gives the wrong answer. The code comment gains the same correction plus the dangling- link case, where the ancestor climb walks past a link that resolves nowhere -- the paragraph above it was true only while the link's target existed. The predicate cannot close either one: it receives the post-normalization path, so by the time it is asked the evidence is already gone. The fix is to resolve against the filesystem before normalizing, which changes what crosses a published interface, and that belongs in its own release rather than bolted onto this one. --------- Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>
Release 0.23.0, because a fix nobody can install is not a fix (#177) * Release 0.23.0, because a fix nobody can install is not a fix The two changes that make capture see a multi-checkout or symlinked working tree merged and then sat on main with no version anyone could pull. Everyone running the plugin or the CLI is on 0.22.0 or older, which still measures session file paths against the single directory the hook fired in — so a linked worktree's files are discarded as another project's, and a checkout reached through a symlink loses its paths outright. That second case needs no worktrees at all, which makes it the more common one. This is the release commit for those: the four version declarations that are lockstep-checked, the CHANGELOG entry, and the committed bundle. The bundle rebuild also carries a change nobody made here. `fast-uri` moved under us in the last dependency update, and the bundle was not rebuilt with it, so the in-sync check has been failing on main since — every push and every pull request. It is a vendored dependency's own URI-parsing hardening, inlined by esbuild the same way the rest of the bundle is; the only reason it appears in a release diff is that a release is the next thing that rebuilt. * Say what the truncation warning actually offers, since it is not what was written Review finding on the release entry. The changelog said the cap warning "names the command that shows you what was skipped". The command it names is `git worktree prune`, which removes stale administrative registrations. That is the right fix when the surplus is stale, and it shows you nothing — `git worktree list` is what shows you. The distinction matters to the reader who has more live worktrees than the cap, for whom pruning changes nothing and some of whose files are quietly not being measured. The entry now says both halves. CHANGELOG.md is not in the published tarball, so this is repo-facing prose and the bundle is untouched. --------- Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>
The wire has said whether to try again for a while; now somebody read… …s it (#173) * The wire has said whether to try again for a while; now somebody reads it A rejected request printed `grounded-ask rejected (502): retrieval_failed` — an internal slug that names one of our call sites and answers nothing a person actually wants to know. The server had already stopped doing that: every relayed failure carries `reason` (`overloaded` = the database was busy, retry is worth it; `unavailable` = we cannot promise that) plus a machine `error` and, when there is one, a real SQLSTATE. The client half was never built, so the wire got strictly more actionable while the terminal stayed exactly as useless. It reads as a sentence now, on every endpoint that sends one: the answer didn't come back. The database was busy — try again in a moment. the answer didn't come back. It failed on our side, and retrying is unlikely to help. The slug and the SQLSTATE are OPERATOR fields and are hidden by default. A SQLSTATE obviously is one; `lesson_persist_failed` is no less one, and printing it was the whole defect. `--verbose` (or BACKTHREAD_VERBOSE=1, which the MCP tools read because they have no command line) appends `[status=502 error=retrieval_failed reason=overloaded code=57014]` — it adds, it never replaces the sentence. WHY THIS IS A MODULE AND A REGISTRY RATHER THAN SIX EDITS. Three modules each held a private copy of the same six-line `serverMessage()` helper (`message ?? String( error)`), so fixing the route that was reported would have fixed one of five and left four. There is one renderer now, plus `CLI_ENDPOINTS`: every endpoint this package can reach, each with a stated disposition — rendered, keeps its own slug map, or never shown, and the last two have to say why in a sentence. What stops the next recurrence is three guards, each of them mutation-killed: * the registry is derived-complete against the `build*Url` / `build*Link` declarations found in source, so a new endpoint is RED until somebody says what a person sees when it fails; * a conformance battery drives all six real entry points through a stubbed rejection and reads the string they actually produce — a unit test of the renderer proves the renderer works and proves nothing about whether a route calls it, which is the exact shape of the original defect; * origin helpers are allow-listed to urls.ts/claim.ts, so an endpoint cannot dodge the registry by inlining `new URL('/x', workerBaseUrl(env))` at a call site. Judgement calls. /infer-decisions is wired through the renderer although it does not send the contract today: it is a worker route carrying the identical helper, and leaving it out is how this recurs. The claim exchange and the login poll keep their own slug maps — they map slugs to specific recovery instructions, which is strictly more actionable than a retry verdict and cannot key off a `reason` they never send. The 409 "a lesson is already being prepared" and the 426 upgrade text are worker-AUTHORED sentences, not relayed diagnostics, and still render verbatim; the narrow `readServerMessage` reader exists so that path can never degrade to the slug. Test floor 715 -> 766: nothing was removed. Three existing tests changed their expected STRING, which is the point of the change. * Two entries said nobody sees this failure, and two commands were printing it An independent read of the previous commit found the registry lying. It classified `read-decisions` and `ingest-decisions` as "a failure here is never shown to anybody", with a plausible sentence each — and both were false. `backthread sync` prints one and `backthread capture --manual` prints the other, so a person was still reading: backthread sync: read-failed — read-decisions rejected (403): not_a_member backthread capture: failed (persist-failed). ingest rejected (502): ingest_persist_failed That is the defect, on routes the registry claimed were out of reach of it, in the same commit that added the registry to stop exactly this. A registry whose justifications are unverified is the failure mode it was built to end, so the fix is the wiring, not the prose. Both are now `failure-body` entries with drivers in the conformance battery. They speak an OLDER vocabulary than the worker — no `reason`, and an `error` that is sometimes a slug (`not_a_member`, `plan_limit`) and sometimes plain English (`token expired`) — so relaying it verbatim was never right either. The strings those two functions actually emit, enumerated from their source, are now a small closed table mapping each to the ACTION it implies: backthread sync: read-failed — you're not a member of the account that owns that repo — ask one of its owners to invite you. backthread sync: read-failed — this device is no longer authorized — run `backthread login` again. A code that is not on that table degrades to the plain HTTP status, never to itself. `client_too_old` is deliberately absent: it ships a server-authored `message` with the exact upgrade instruction, and the message branch renders that verbatim. Two guards also got sharper, both driven by measurement rather than by taste: * a BARE 410 on the in-flow answer path had no test. The status check that catches it is defended in a comment as load-bearing, and deleting the line was invisible to the whole suite — a surviving mutant. It now reads back the "nothing is owed and nothing is missing" reassurance instead of "HTTP 410". * the centralisation guard knew about `workerBaseUrl()` and `functionsBaseUrl()` but not about the exported ORIGIN CONSTANTS beside them, so `new URL('/x', DEFAULT_WORKER_URL)` built an endpoint the registry would never have seen. Both spellings are covered now, verified by breaking it. The CHANGELOG claim that capture was covered is corrected: only its infer leg was. Both legs are now. Test floor 766 -> 780. Nothing was removed; the pre-existing persist-failure test had been asserting `/500/`, which the new sentence satisfies by accident, and now pins what a reader actually gets. * "Nobody prints it" is a fact about the caller, so the word now means something else The last never-shown entry standing was onboarding-state, justified on the grounds that its one caller discards the detail. That is true today and it is the wrong kind of true: the module was still BUILDING `onboarding rejected (401): invalid token`, complete and ready, and the only thing between that string and a reader was a caller choosing not to print it. A second caller inherits a leak nobody wrote. So it renders like the rest, through the same table — an expired credential now says to run `backthread login` — and `never-shown` is down to one endpoint: the pre-send capture-scope preflight, which reads a verdict and never a diagnostic. That one member is what makes the category checkable, which it was not before. `never-shown` is the single disposition the registry cannot verify by driving code, so it was a promise — and an outside read found the promise false on three entries at once, each carrying a plausible sentence. It is now held to the strong reading of the word: a module serving a never-shown endpoint must not read `error` or `message` off a response AT ALL. Then there is no sentence to leak, whoever prints what. A source-level property rather than a behavioural one, which is the honest limit here — but it is checkable, and a promise is not. Both halves are mutation-proved: adding a diagnostic read to captureScope.ts goes red, and blinding the caller-finder goes red on the companion test rather than passing vacuously over a list it failed to build. Test floor 780 -> 786. * The error field was never only slugs, and hiding all of it made three failures worse A second independent read caught the founding assumption of this module being false. `error` carries TWO kinds of thing. `retrieval_failed` is a machine code and belongs behind --verbose. But `repo not found or not connected to Backthread`, `not authorized to read this repo` and `no repo given and none connected — pass repo:"owner/name"` arrive in the SAME field, and those are sentences somebody wrote for a reader. Hiding them made the three most common non-transient failures of how / learn / ask-me read worse than before any of this started: main query: read-failed — grounded-ask rejected (404): repo not found or not connected to Backthread before query: read-failed — the answer didn't come back. The server rejected it (HTTP 404). now query: read-failed — the answer didn't come back — repo not found or not connected to Backthread The predicate that tells them apart is the server's own, mirrored: lowercase, digits and underscores is a machine code; the first space, colon or capital means prose. It also stops --verbose dressing a readable sentence up as `error=repo not found or not connected to Backthread`. THE SLUG TABLE IS NOW A DEFAULT, NOT AN ARGUMENT. It was passed in by the two Supabase-Functions call sites only — but the worker's deviceAuth emits the same auth vocabulary, so one expired token gave two answers depending on which command you ran: sync said "run `backthread login` again", how said "the server rejected it (HTTP 401)". This ticket's own words — "or this recurs one endpoint at a time" — reproduced inside the fix, by its own mechanism: something correct that each call site had to remember. A default cannot be forgotten. All four commands now agree. EVERY DEAD END NAMES A NEXT STEP. "Retrying will not help" and then silence leaves the reader with nothing to do, which is the state the ticket asks us not to leave them in — and cli/README.md was already claiming otherwise. Which step depends on who is reading, and the verbose switch already knows: a reader is told how to GET the detail, an operator is told where to SEND it. THE ENDPOINT GUARD LET SEVEN THINGS PAST. `export const`, `export async function`, a builder not named build*Url, `base + '/path'`, an intermediate const, a hardcoded workers.dev URL, and a driver that called describeFailure instead of the route it claimed to test. Every one an ordinary spelling. Chasing shapes was the mistake: all of them start by naming an origin, so the ORIGINS are the allow-list now — outside urls.ts, claim.ts and doctor.ts (which probes bare hosts and constructs no path), do not name one. A builder is told from a consumer by whether it fetches, not by what it is called, and the driver table must name its registered entry point. All seven verified caught. Two mutants that had survived are dead. `ask_expired` is only ever sent as a bare 410, so its override entry was unasserted redundancy a server-side status change would have silently activated wrong — it is now driven as the non-410 it is defended against being. And the capture-scope skip printed `capture skipped (not_a_member)`: a closed client-side enum on a 200, so not a server diagnostic at all, but a slug in front of a person however respectable its provenance. It reads as a sentence now, and the table is held exhaustive against the enum declaration. Test floor 786 -> 798. * A refusal is not a bug, and five more ways past the guard that was supposed to be one A third independent read. Every finding was real. THE BUG-REPORT TAIL WAS APPENDED TO REFUSALS. Naming a next step after "retrying will not help" was right; appending it to everything was not. A lesson rate limit, an answer that belongs to somebody else, a repo this device cannot write to — all deliberate answers from a working system — ended with "report what that prints at github.com/…/issues". Confidently wrong is worse than silent. The tail is now gated on the status: a 5xx is plausibly ours, a 4xx is the server answering. And it said "the detail above" while the operator suffix is appended to the END of the same line, so it now says where the detail actually is. Thirteen more slugs are mapped, enumerated from both servers rather than guessed: the repo gate, the four bad-request codes (which are OUR bug and now say so rather than inviting the reader to look for their own mistake), the deliberate refusals, and the auth vocabulary. Measured, three of them: 0.20.0 backthread learn: lesson start rejected (429): lesson_retry_too_soon now a lesson for this repo was built very recently — give it a while before asking for another. 0.20.0 ingest rejected (500): duplicate key value violates unique constraint "decisions_pkey" now the write didn't complete on our side. Nothing was saved; try again. 0.20.0 ingest rejected (403): repo_not_writable now this repo is connected to an account this device can't write to — ask one of its owners to invite you. The second of those also kills a claim this module made about itself. It said a relayed body can never carry `message`, because the worker's allow-list drops the key. True of `failureBody` — and two of the routes rendered here do not go through it, and pair a slug with `(e as Error).message`. So a Postgres string was arriving as product copy. The fix is not a cleverer reader: both slugs are on the table, which is consulted first. FIVE MORE ESCAPES FROM THE ENDPOINT GUARD, and one from the guard on the guard: * doctor.ts could host a complete unregistered endpoint — it was on the origin allow-list, so it could name an origin, and outside the builder scan, so nothing asked what it did with one. Permission to name an origin was being read as permission to skip the registry; * reading BACKTHREAD_WORKER_URL straight off env dodged a token list that only knew the helpers; * a private fetch helper appended AFTER a new builder in claim.ts hid it, because the scan skipped any part containing a fetch call and both landed in the same part. A builder is now told from a consumer by its own RETURN TYPE, which belongs to one declaration and cannot be smuggled in from a neighbour; * own-slug-map was never-shown with a different label: 61 characters of prose and nothing checked. It is now DRIVEN — hand it a slug its map never heard of and watch whether the slug comes back out; * the never-shown diagnostic guard was dot-notation over a fixed list of variable names, so rec['error'] walked past; * and the check that each driver drives its registered entry point was satisfiable by naming the function in a trailing comment. FOUR SURVIVING MUTANTS, all now dead. The lesson 409's fallback was untested because its only test sent `message` AND `error`, so reverting to the old helper still picked the message — while a 409 without one printed `lesson_in_progress` to a person. The caller-written `lead` could be deleted from two of four branches in silence, and the lead is the entire mechanism behind different commands naming different things. And the two capture-scope sentinels were type-kills only, one of which is reachable the day the server adds a scope reason. Test floor 798 -> 809. * A prototype is not a sentence, and advice about owners who do not exist A fourth independent read. Four user-visible defects and six more guard escapes. THE LOOKUP WAS A BARE INDEX INTO AN OBJECT LITERAL, so a server sending `error: "toString"` made the ENTIRE line the reader saw: backthread learn: function toString() { [native code] } No lead, no verdict, no status, no next step. Eight `error` values did that — constructor, __proto__, valueOf and the rest — and --verbose did not rescue any of them. The module's own doc promises it never trusts the payload's shape; an inherited property is exactly the shape it forgot. Own-properties only now, on both tables, and the eight are a named test. TWO SENTENCES GAVE ADVICE ABOUT PEOPLE WHO DO NOT EXIST. `repo_not_writable` and `not_readable` fire on exactly one condition in every producer — the repo has no owning account — and both said "ask one of its owners to invite you". The scope table said it a second time, and the changelog a third. Read the producer before writing the sentence: they now say the repo has no owning account and what to do about that. THE MCP SURFACE WAS TOLD TO PASS A FLAG IT HAS NO COMMAND LINE FOR. The next step said "run it again with --verbose" everywhere — including the one surface this module's own doc names as the reason the switch is an env var. It now names both spellings, which is true on every surface. Same class as "the detail above", which this branch already fixed once. `ask_bad_signature` is mapped. The token verifier has three verdicts and emits `ask_${reason}` for each; two were covered, so its identical sibling on the same server line read "the server rejected it (HTTP 400)" with no next step. SIX MORE ESCAPES, and the pattern in them is the same one every round: * the file walk was a FLAT readdir, so everything under cli/src/bin/ — and any subdirectory a future author creates — was invisible to every guard here; * a new module could derive its endpoint from an existing builder (`buildLessonStartUrl(env).replace('/lesson/start','/lesson/next')`) or invent a host on an unlisted domain, and neither names an origin, so the origin ban never fired. Both start by CALLING FETCH, which is the property — a request leaving this package — rather than any of the shapes a URL can be spelled in. So the fetchers are named, and adding to that list is a deliberate act; * three ways past the builder scan's return-type test: no annotation at all, an alias for string, and returning a URL. All ordinary spellings. The scan now uses brace-matched extents and asks whether the declaration FETCHES, which is what actually separates a builder from a consumer; * the fake-driver guard, having been closed against comment placement, was reopened by dead code. Text is arguable, so the drivers are now OBSERVED: the fetch each is handed counts its own calls, and a driver that never made a request cannot have driven a route whatever its source says. Three survivors are dead. The "every entry is reachable" test iterated the table and asserted things about what it found — a restatement, not a check, and seven entries were deletable in silence. The vocabulary is now a CLOSED written-down contract asserted set-equal to the table, which is the best a package that cannot import the server can do, and it fails in both directions. The override-beats- global direction is asserted. And the ban on private copies of the old helper looked for `function serverMessage(` — which saw two of the seven modules that had it; the other five inlined the same nested ternary, which is the form a future author is likeliest to reach for. The SHAPE is banned now, and a test proves the pattern still recognises the exact text that stood in those seven modules. Also: four dead SLUG_COPY keys removed (routes this package never calls, so no test could reach them and "exhaustive" quietly meant nothing), the dead `status === 0` clause removed, and three doc claims corrected — "five routes" (seven, five reachable), "three modules" (seven), "byte-identical copy" (it wasn't), plus the changelog's "a 4xx never does", which is now true by construction rather than by nothing-sends-that-yet. Test floor 809 -> 817. * Nobody writes copy for a 500, and four sentences described conditions their servers do not have A fifth independent read. RAW POSTGRES TEXT WAS STILL REACHING A PERSON. The previous fix mapped the two slugs that pair themselves with an upstream string — slug-specific, and the general path was wide open. Three Edge Functions this package calls do `json({ error: message }, 500)` with the caught string, so it arrives in the CODE's own field, fails the machine-code test, and rendered as a sentence somebody wrote for a reader: backthread sync: read-failed — the decision log didn't sync — could not serialize access due to concurrent update "Not a machine code" was never the same question as "written for a person", and the status answers the real one: nobody writes reader-facing copy for a 500. A 4xx `error` string is the server answering the caller's own request and still renders verbatim — that was a measured regression once and stays fixed. A 5xx `error` string is a relayed diagnostic and is treated as one: the reader gets "it failed on our side" plus where to report it, and the operator still gets the string, because on that path they are the only one who can use it. FOUR SENTENCES DESCRIBED CONDITIONS THEIR PRODUCERS DO NOT HAVE: * ask_bad_signature said "not issued to this device" — but a token issued to another device verifies fine and comes back as ask_not_yours. The two sentences described each other's conditions; * lesson_retry_too_soon said "a lesson was built very recently" — the opposite of the truth. It fires when the last few paid builds left NO lesson behind; * plan_limit said "decision limit", which is the wrong noun (it is a capture SESSION allowance) and echoed a pricing model that was retired; * not_readable fires on the identical condition as its sibling and was the only one of the pair left with no remedy to name. `ask_material_moved` is on the global table now — it is global on its route, and sitting only in the route-local overrides made the "absent on purpose" list wrong. `forbidden`, `repo_not_connected` and `unauthorized` are gone: no reachable route sends any of them, and `forbidden`'s sentence had re-committed the "ask an owner who does not exist" defect in a sibling of the entry written to fix it. THREE MORE ESCAPES, and the pattern is the one every round has had — a guard aimed at a spelling rather than at the property: * `const send = deps.fetchImpl ?? globalThis.fetch` walked past a ban on `fetch(`. A module that cannot NAME fetch cannot call it however it spells the call, so the identifier is the allow-list now (string literals excluded — a log line reading "state fetch failed" cannot send anything, and flagging it would teach the next author that the ban is noise); * `not-a-fetch-target` was the one category with NO check at all, not even the prose bar. A real worker endpoint registered as "just a link we print", then fetched, was entirely green. A link is a claim about behaviour, so it is asked as one; * `const { error: slug } = rec` walked past a diagnostic-read guard that knew dot and bracket notation. THE COPY IS NOW ASSERTED SENTENCE BY SENTENCE. Fifteen of these were pinned only by "longer than twenty characters, doesn't name itself, isn't the fallback", so rewriting one to say something wrong stayed green — and two mutants that reverted the LAST commit's headline fix were invisible. Same for the per-route leads: the test asserted six of nine were distinct while its own comment claimed a lead deleted anywhere would collapse the set, and four collapses survived. Both are named tables now. --verbose also means something again on the two paths that answer before the renderer is reached — the 409 and the bare-410 — which are right to write their own sentence but were making the switch a lie on the two paths somebody is most likely to re-run. Doc claims corrected: the changelog quoted a fenced "example output" line that exists nowhere but the changelog (the wording the previous commit removed for being unfollowable on MCP), "verbose adds, never replaces" (it replaces the next-step clause), "a dead end always names the next step" (a mapped refusal names its own remedy instead), and the --help + README line claiming the HTTP status is hidden by default when the fallback prints it. Test floor 817 -> 826. * Endpoints are born in one module now, because chasing spellings never ends A sixth independent read. One ship-blocking finding, and it is the same finding as every round before it wearing new clothes. TWO ORDINARY SPELLINGS STILL ADDED A COMPLETE UNREGISTERED ENDPOINT, green on the whole suite, rendering a raw slug to a person: * route the origin through a one-line private helper inside urls.ts and the builder's own brace-matched extent is clean, so the scan does not call it a builder. The comment two lines above claimed "a const base = workerBaseUrl(env) hop" was closed. It was closed at module scope only; * write ONE function that both builds a URL and fetches it, in claim.ts or doctor.ts. A declaration containing fetch( was classified a consumer and skipped — and that is the codebase's own house style, just with the URL built inline. Three shipped claims said otherwise, including the release notes. The fix is to stop discriminating. Every previous version of this scan tested a SHAPE — the name, then the return type, then whether an origin appeared in the body — and every version was walked past by something an ordinary author would write. So `buildExchangeClaimUrl` moved into urls.ts, and urls.ts is now the only module where an endpoint can be born. It is already forbidden to fetch, so "built here" and "used there" can never be the same declaration. Every export of it that is not an origin helper must be in the registry, with no test of what its body says or what its signature returns. There is nothing left to spell differently. doctor.ts keeps permission to name an origin because it probes bare hosts for reachability — and now must construct no path, which is a stronger statement than the scan it replaces. claim.ts loses the permission entirely, so the build-and- fetch shape there trips the origin ban. One more raw diagnostic, found in the same read and outside this ticket's literal scope: `backthread login --claim` printed Exchange failed (HTTP 500) — permission denied for schema private That endpoint's 500 arm pairs its slug with the caught upstream string, and the default branch relayed `message` unconditionally. Same rule as everywhere else now — a 4xx message is the server answering the caller, a 5xx message is a diagnostic — and the line gained the recovery instruction its siblings already had. Also: the poll endpoint's states were misnamed in its justification (pending / ready / expired / consumed, not "claimed"); the exchange entry's justification described only its mapped-slug branch and was silent about the default one; the two-sentence rationale contradicted the prose branch, which deliberately joins with a dash because a server-authored sentence is an appositive and somebody else's words; and the order of the two authored-text readers was unasserted, so swapping them survived. All named tests now. Test floor 826 -> 830. * The guard could not see a URL, because its comment-stripper ate every one of them A seventh independent read. Five more spellings past the endpoint guard, and the root cause is one line I wrote six rounds ago. `stripComments` was `src.replace(/\/\/[^\n]*/g, '')`. That eats from the `//` in 'https://app.backthread.dev'. So every guard built on it — including the one that bans hardcoded URLs — was reading truncated source and structurally could not see the thing it existed to find. It is a scanner now: a comment and a string can only be told apart by walking the file. That alone is why the "no hardcoded URL" rule had never once fired. The five escapes, each a complete unregistered endpoint the CLI requests and whose failure it prints as a raw slug: * `buildLessonStartUrl(env).replace('/lesson/start', '/lesson/next')` in lesson.ts. Naming the fetchers stops a NEW module; inside one already on the list nothing asked what the request was pointed AT. And /lesson/next is a real worker route that emits the contract, so this was the ticket's own defect one ordinary line away; * a literal host on a domain the host pattern did not list; * a builder named DEFAULT_LESSON_NEXT_URL — the exemption for origin helpers was a NAME SHAPE, `/^…|DEFAULT_\w+_URL$/`, which is the exact mistake every prior version of this scan made. Six names, written down, is what it should always have been; * `function buildX(){…}` plus `export { buildX };` — an ordinary spelling the export matcher never looked for; * an origin interpolated in front of a path in doctor.ts, which the broken stripper erased before the guard could look. The property is "every URL a request is handed traces to a registered builder call", and it is now checked as that rather than as a list of shapes: no literal URL in any module that can fetch, and no surgery — replace, concat, slice, `+` — on a registered builder's result ANYWHERE, since the edited URL only has to reach a network module to be requested and does not have to be edited in one. A LITERAL URL IS STILL FINE WHERE NOTHING CAN FETCH. This module points a reader at an issue tracker and `sweep` builds a GitHub clone URL; banning those would be a vocabulary rule rather than a safety one, which is how a guard earns the reputation that gets it deleted. Two things a person reads: * the changelog said "Only the first is hidden" of the two kinds of thing `error` carries — false since the previous commit, which hides prose on a 5xx. It now says which kind and on which status; * `login --claim` printed `…Upgrade to connect a capture device.. Generate a fresh code at … and try again.` — a double stop and "try again" twice with different antecedents. When the server's sentence already ends in one, it speaks alone. And one for whoever is fixing things: a 5xx `message` was withheld from the reader (right — it is upstream text) and from the operator too, which just lost it. It is in the --verbose suffix now. Test floor 830 -> 835. * Follow the value, not the syntax — and a 500 is never the reader's fault An eighth independent read. One regression I caused last round, eight more ways past the guard, and two invariants that rested on a table staying exhaustive. THE REGRESSION IS MINE. Last round I gave `login --claim`'s unmapped arm the recovery instruction its siblings had. On a 500 that is three wrongs in one line: it points the reader at their own claim code as the cause of a server fault, tells them to do work that cannot help, and — because the same commit had stopped relaying `message` there — loses the diagnostic for the operator too. That arm now goes through the shared renderer, which already knows how to say all of it: 0.20.0 Exchange failed (HTTP 500) — permission denied for schema private now this device could not be authorized. The server rejected it (HTTP 500). If it keeps happening, set BACKTHREAD_VERBOSE=1 … and report what that prints now -v … [status=500 error=exchange_failed message=permission denied for schema private] TWO INVARIANTS RESTED ON A HABIT, which is what this ticket exists to stop: * `readAuthoredError` was status-gated and `readServerMessage` was not, so "no raw database text" held only while SLUG_COPY stayed exhaustive over every 5xx slug that pairs itself with an upstream string. One new server slug re-opened it. Both fields are gated now — nobody writes reader-facing copy for a 500, in either of them; * the global slug table outranked the wire's own retry verdict, and two of its sentences end "try again" — the CLI overruling the server about the one thing the server knows better. `unavailable` now outranks the global table. A ROUTE-LOCAL override still wins, because it exists precisely because that one route knows something the wire does not. EIGHT MORE ESCAPES, and I have now been told four rounds running that the guard was a list of shapes. It was: `replaceAll` where `replace` was banned; one `const` hop before the edit; `new URL(path, base)`; URL-object `pathname` surgery; the app origin plus a path (app.backthread.dev really does serve /api/*); an address parked in a non-network module and imported into one; `export let`; and an origin bound to a local then interpolated. The rule follows the VALUE now. Every identifier in a file that traces — through any number of bindings — to a registered builder or an origin helper is collected first, and then editing or interpolating ANY of them is what is banned, not a list of method names next to a call. Plus: no `new URL(` in a module that can make requests, and no literal address written down ANYWHERE, since where a URL is written and where it is requested need not be the same file. Two files keep a literal because it is a link and not an endpoint — the issue tracker a dead end points at, and the GitHub clone URL sweep builds — and both are named, so a third is a decision somebody makes rather than a side effect of where they typed it. The eight spellings are fixtures in a vacuity test, so if the rule ever stops matching one of them it has quietly become the pattern-matcher it replaced. Also from that read: the last unpinned copy table. SCOPE_REASON_COPY is printed by `capture --manual` and was guarded only by non-empty and not-self-mapping, so three of its sentences could be swapped for each other's in silence — four surviving mutants. It is asserted sentence by sentence now, like SLUG_COPY. The appositive dash is pinned. `ask_material_moved` no longer exists twice. A single word with no space is no longer treated as a sentence somebody wrote for a reader. And the worker's RESERVED_KEYS is a deny-list, which is what this file now calls it. Test floor 835 -> 840. * The discriminator was never the status, and a guard cannot prove its own soundness in prose A ninth independent read. One regression, more escapes, and a claim I should have stopped making rounds ago. THE REGRESSION IS MINE AGAIN, AND IT IS THE SAME MISTAKE IN THE OPPOSITE DIRECTION. Last round I sent every 5xx on the claim exchange through the shared renderer, on the grounds that nobody writes reader-facing copy for a 500. That endpoint has TWO 500 arms and they are opposites: `exchange_failed` pairs its slug with the raw upstream string, while `mint_failed` pairs it with a sentence somebody wrote on purpose — "… — the claim code is spent; generate a fresh one." — and by then the code really is burned, so a fresh one is the only recovery there is. I replaced the one true instruction with an invitation to retry and file a bug. 0.20.0 Exchange failed (HTTP 500) — could not allocate token — the claim code is spent; generate a fresh one. now Exchange failed (HTTP 500) — could not allocate token — the claim code is spent; generate a fresh one. The discriminator is the SLUG, not the status. Both arms are now pinned, in opposite directions, so neither generalisation can be made again. TWO MORE ESCAPES, and I have been told five rounds running that the rule was still a list of shapes: * a RENAMED import. `urlBindings` followed `const|let|var` and not `import { X as Y }`, so one rename stopped the trace at the door; * `\bfetch\b` does not match `fetchImpl`, so a module taking the sender as an injected dep never tripped the network allow-list at all. That is a plain bug in a regex I wrote, and it made the "declared fetchers" rule optional for anyone using this codebase's own dependency-injection style. Plus seven shapes that were plainer than anything in my fixture list: `String(b) .replace`, bare parentheses, `(b ?? '')`, an object property, a function-return hop, `new globalThis.URL`, and `${base}${'/path'}`. The rule follows the value through bindings, imports and function returns now, and asks whether an edit verb appears anywhere in the same STATEMENT rather than adjacent to a name. AND THE RULE AND ITS OWN PROOF WERE TWO TRANSCRIPTIONS OF ONE IDEA. The "not vacuous" test carried a copy of the regexes, and they drifted — so the fixture list went on passing against a version of the rule the package no longer used. One implementation, called by both. WHAT I HAVE STOPPED CLAIMING. Four rounds running, an outside read has falsified some absolute form of "nothing can get past this", and each time I closed the spelling and re-made the claim. The claim was the defect. What is true, and is what the file and the changelog now say: the ordinary ways to add an endpoint are red at three independent points — an unregistered builder, an undeclared fetcher, and a URL that was invented or edited. A determined author can still dodge a source-level trace, and a reviewer should still read a diff that adds a request. What this stops is FORGETTING, which is what actually happened: the defect this whole change exists for sat on five routes at once because nothing ever asked. Test floor 840 -> 842. * Half that sentence was written for a reader and half was a Postgres error A tenth independent read, and the last three findings are small enough to name in one line each. `mint_failed`'s server message is `${lastFail} — the claim code is spent; generate a fresh one.` I relayed the whole thing last round because the suffix is deliberate copy. It is — but `lastFail` is `error.message` from the register RPC, raw Postgres on one of its two arms, so a reader got: Exchange failed (HTTP 500) — permission denied for schema private — the claim code is spent; generate a fresh one. The recovery is the half that matters and it never varies, so the client writes it now and the server's string goes behind --verbose with everything else: That claim code is spent — it was consumed even though the device could not be authorized. Generate a fresh code at https://app.backthread.dev and try again. I ALSO SAID I HAD STOPPED CLAIMING THE GUARD WAS UNEVADABLE, AND THEN DID NOT. The narrowing went into a commit message; the shipped comment still ended "there is nothing left to spell differently", and a test was still named "nothing can dodge the registry". Both are now what is actually true: the ORDINARY ways to add an endpoint are red at three independent points, it is a source-level trace rather than a type system, a determined author can still dodge it, and what it stops is forgetting — which is what actually happened, because the defect this change exists for sat on five routes at once with nothing ever asking. The changelog says the same. `export default` is closed as well, since it cost one alternation. Two surviving mutants, both on lines this branch added and both reader-visible: deleting the "generate a fresh code" tail from the unmapped-4xx arm left a status and no next step; and turning the verdict's full stop into a dash gave a line three em-dashes, which is the thing the module argues against by name. Pinned. Also: the poll endpoint's states were misnamed in its justification again (pending / ready / expired / consumed), and a comment counted two answer-before-the-renderer paths where there are more. Test floor 842 -> 844. * A regex literal with one quote in it blinds every guard below that line An eleventh read cleared this to ship and left one thing worth doing anyway. `stripComments` walks strings but knows nothing about regex literals — and it cannot cheaply be taught to, since telling `/['"]/g` from a division needs a parser. So one unbalanced quote inside a regex desynchronises the scanner and everything after it reads as string content, which blinds the hardcoded-URL check for the rest of that file. Measured: a literal address placed after such a regex in `lesson.ts` went green; the same address above it was caught. No file trips it today. What makes that a fact rather than a hope is a probe: append a sentinel address to each file's source in memory and assert the check still sees it. A future edit that desynchronises the scanner turns THAT red, wherever it happens, without anybody having to remember why — which is the only kind of guard this change has any business shipping. Mutation-proved by adding `const __Q = /['"]/g;` to a network module. Two changelog counts corrected: "three" routes put the upstream string in the code's own field on a 500 (five do; three of them reached a reader, which is what I meant and not what I wrote), and "every export must be in the registry" rounds off the origin helpers that the source comment names precisely. Test floor 844 -> 845. * Inspecting the tarball found the ticket's own example string still in it The pre-publish check was to run the packed artefact rather than the worktree, and grepping the bundle for the strings this change was supposed to remove turned up `grounded-ask rejected (` still in there. Not a stale bundle — a path I had never covered. `describeFailure` renders every failure that came back WITH A BODY. A timeout, a dead socket, or a retry loop that gave up have no body by definition, so those paths kept their original wording and nobody had looked at them: 0.20.0 query: read-failed — grounded-ask request failed: fetch failed (after 2 attempts) — try again. now query: read-failed — the answer didn't come back. Backthread could not be reached — check your connection and try again. -v … [route=grounded-ask attempts=2 cause=fetch failed] That is a bare internal route name in front of a person, on the very command the ticket names, and "no slug came off the wire" was never the same as "this is product copy". Four sites had it: the grounded ask, the decision-log sync, the capture write-up and the setup check. The route and the transport error are operator fields like every other diagnostic and go where those went. TWO KINDS, NOT THREE. A first draft had a `server` kind for "every retry returned a 5xx" — and that state does not exist. The retry loops break on the LAST attempt with the response still in hand, so a run of 5xx ends at `describeFailure` with a real body, which is strictly more informative. An unreachable branch is exactly what this change keeps finding in other people's code, so it is gone and the fact that makes it unreachable is now a test. Verified from the packed artefact rather than the tree: `npm pack`, extract, install into a clean directory, run that binary. The bundle in the tarball is byte-identical to a fresh build of the final src (sha 57d3e22f…), and the only occurrences of the old strings left anywhere in it are two comments quoting the defect they document. Test floor 845 -> 847. --------- Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>
The symlink-escape guard now fails when it stops working (#172) * The symlink-escape guard now fails when it stops working A verifier mutation-tested the extractor and one mutant survived in the worst place: `lstatSync(p)` → `statSync(p)` in `enforceSourceBudget`'s walk — making the symlink-escape guard FOLLOW symlinks, precisely what its own comment says it must never do — left the whole suite at 2175 passing, exit 0. The path-traversal guard had no test that failed when it broke. It matters more than a normal coverage gap. `safety.ts` runs inside the ephemeral container before extraction, against untrusted repositories, and is the only thing between a hostile repo's symlink and the container filesystem outside the clone — a container that also holds a GitHub token. It ships in the public MIT package whose selling point is that source never leaves the machine, so it is exactly the file an evaluating engineer reads to check whether we mean it. A silently-broken traversal guard undercuts the claim the package was published to make auditable. REAL TEMP TREES, NEVER A MOCKED `fs`. The guard's whole job is what the filesystem actually reports — `lstat` versus `stat`, what `realpath` resolves a link to, whether a dangling link throws — and the surviving mutant is a one-word difference between two of those calls. Every fixture is built on disk, including actual symlinks out of the root, one of them relative (`../../`), which is the shape a repository can really commit. `realpathSync` on the fixture root is load-bearing rather than tidy: on macOS `os.tmpdir()` sits under `/var`, itself a symlink to `/private/var`, and the guard compares resolved targets against a resolved root. A fixture keeping the unresolved path would make every in-tree symlink look like an escape — passing for the wrong reason, and still passing with the guard removed. TWELVE MUTANTS, TWELVE KILLS, against a baseline verified green first: lstat→stat (symbol-preserving, so the kill is behavioural and not an unresolved-symbol crash: 8 failures) · the escape rejection deleted · the separator dropped from the prefix match, which is the `/tmp/clone-12345-evil` bypass · maxFileBytes raised · each of the three budget comparisons inverted `>` → `>=`, which a single "too big throws" test cannot see · a SKIP_DIRS entry removed (twice — it must not throw, so the budget is the instrument) · every directory skipped · the dangling-link catch widened to swallow escapes. The skip-list block carries its own negative control: without one, a mutant that skipped EVERY directory would satisfy all six entries by counting nothing at all. The symlink block carries two: an in-tree link must still be ACCEPTED, and a dangling one ignored, or a guard that threw on every symlink would pass the escape tests and break every repo that vendors a link. And the harness asserts its own preconditions: that `symlinkSync` really produced a symlink (a runner without the privilege could silently make a copy, and every escape test would then pass over a plain file) and that the "outside" tree really is outside the root. Floor for @backthread/extractor: 2175 -> 2197. * Four mutants survived the suite that exists to catch survivors Review mutation-tested this PR's own tests and found four of its guarantees unpinned. A change arguing "a guard with no failing test is not guarded" shipping four surviving mutants is the same defect one level up, so all four are closed. THE ORDER OF THE TWO CHECKS IS LOAD-BEARING, and nothing said so. `lstat` and the symlink branch run BEFORE `SKIP_DIRS`. Hoist the skip check above them and every existing test still passed — while an attacker gains six names they choose (`.git`, `node_modules`, `dist`, `build`, `.next`, `coverage`) that walk straight past the traversal guard. `ln -s /etc node_modules` is a one-line repository. Pinned now for every name on the list, not one of them. THE DEFAULT BUDGET WAS THE ONE PATH NOBODY TESTED, and it is the only one that ships: nothing calls `enforceSourceBudget` with a budget, so every cap comes from the default parameter — which could be widened to Infinity with the suite green. Covered now by one file a byte over the real `maxFileBytes`, called the way the container calls it, with its at-the-cap pair so it is the cap biting rather than "a big file throws". THE SELF-LINK BRANCH: deleting `target !== rootReal` makes `ln -s . self` fail the ingest of an otherwise innocent repository, silently. Repos really do commit that. AND THIS FILE'S OWN REASONING WAS WRONG. It claimed fixtures must pre-resolve their root or in-tree symlinks would look like escapes. False — `enforceSourceBudget` calls `realpathSync(root)` itself. Because every fixture pre-resolved anyway, replacing that call with `resolve(root)` survived the whole suite. A test resting on a false premise is a test nobody can maintain, so the note is corrected and an unresolved-root case now covers the mutant on both halves: an in-tree link still accepted, an escape still caught. Three smaller ones, all real: the only negative control was the file's only bare `toThrow()`, so it went green on any error including a broken fixture — it names its error now. A precondition described an `lstat` check in prose and never made the call, which is precisely the defect under test. And cleanup moved off a top-level `afterEach`: under `--experimental-test-isolation=none` that registers on the shared ROOT context and fires around every test in the run rather than this file's, so each test now owns and disposes of its own fixtures in a `finally` — no reasoning about the runner's context model required. Nine mutants re-run against a green baseline, nine kills, including all four survivors. Floor 2197 -> 2201. * A guarantee pinned only on the author's machine is not pinned Verification ran 36 mutants and found the resolved-root test platform-dependent. It relied on `os.tmpdir()` being reached through a symlink — true on macOS, where `/var` links to `/private/var`, and FALSE on the Linux runner CI actually uses, where it is plain `/tmp`. Measured: with the `realpathSync(root)` → `resolve(root)` mutant applied under a non-symlinked TMPDIR, the suite stayed at 26 of 26. The test was not flaky; it simply stopped testing anything. So the fixture now builds its OWN symlinked route to the root and passes that as the root. The symlink is ours rather than the operating system's, so the mutant dies everywhere. AND ONE GUARANTEE IS RECORDED AS UNPINNED RATHER THAN QUIETLY MISSED. The docstring promises a throw on the FIRST violation, and for a zip-bomb guard aborting mid-walk is most of the value — but hoisting the two count checks out of the loop leaves every test here green, because the same error is still raised, just later. No honest deterministic test exists: `readdirSync` order is unspecified, so every "the walk stopped before reaching X" fixture depends on which entry the filesystem returns first, and the only order-independent instrument is a timing assertion, which is flaky by construction. A guard that fails on a fast machine and passes on a slow one is worse than a stated gap. Pinning it needs an injectable readdir counter — a change to safety.ts's signature for testability, which is a decision for whoever needs the guarantee. Two further survivors are proved EQUIVALENT rather than missed (the `continue` after the symlink branch; `walk(resolve(root))` → `walk(root)`), and a third — `else if (st.isFile())` → `else` — is reachable only by a FIFO, socket or device node, none of which git can check out, and makes the guard stricter. All three are named in the file so the next mutation run does not re-discover them as findings. --------- Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>
Say what a lead can see, at the moment we ask someone to supply data … …about themselves (#170) * Say what a lead can see, at the moment we ask someone to supply data about themselves The `ask-me` promise said "your lead sees what the team understands, never who replied". Both halves were false. X was the team's AGGREGATE, and it is not the upper bound any more: a lead sees what each NAMED person understands, as a split. Y was "who replied", which is inferable — credit for a demonstrated answer exists only where that person's graded row does, so a non-zero figure beside a login says they replied. The server statement was corrected already; the CLI's three copies of it were not. This aligns them, quoting the server rather than paraphrasing it: "Your lead sees what each person understands, never how much anyone took part. 'Did not answer' and 'was not asked' are the same thing here, because neither one was recorded." That version is checkable. Participation volume genuinely is not measured anywhere, and the collapse of "did not answer" into "was not asked" is what makes the not-an-exam claim falsifiable rather than reassuring. The README is the sharpest case: it says the statement "comes from the server that enforces it, so it cannot drift from the behaviour it describes the way a README can" — and then its own one-line outline had drifted. So the three copies are now pinned against each other by `promiseWording.test.ts`, which asserts the claim as a SHAPE (so punctuation may differ), bans both retired wordings by name, and carries a negative control proving the pattern rejects the old sentence and accepts the new one. Without that control, a regex matching nothing would pass every assertion in the file. Mutation-tested, 5 mutants, all killed: README reverted · ask-me.md reverted · fixture reverted · README drops the sentence entirely · the falsifiable half dropped from the fixture. A false privacy promise is worst exactly here — at a consent moment, in the text someone reads while being asked to supply data about themselves — and it is the easiest kind for a reader to discover. Version 0.19.0 → 0.19.1 via the lockstep script; test floor raised 723 → 729. * Say in the file what a green run does not prove REVIEWER [low] — the fixture comment calls the server "the only place this statement is authoritative", and nothing in this repo checks the copy against it. The test pins the three copies TO EACH OTHER, so all three can be consistently stale — a smaller version of the failure this change fixes, where the README asserted the server was authoritative while its own outline had drifted. The alignment was verified by hand for this change (the fixture string and the server's are byte-identical, 171 characters, same quoting), but that check lives in a terminal and this repo cannot import from the private one. So the limitation is now stated in the file header, along with what it would cost to close it: fetching the live --promise output in CI means a network call and a signed-in session on every run, which is worse than a documented manual step. --------- Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>
A claim that lives only in the payload cannot be refused cheaply (#169) Review of the ingress found the shape this fixes. The ingress cannot read a request body until it has authenticated, checked for a replay, and decompressed up to 16 MiB — so a claim carried only in the payload puts every refusal that depends on it below that read. Anyone with a GitHub account could mint a token for a repository they own and post a small gzip that inflates to 16 MiB, repeatedly, to be told the same thing one indexed lookup already knew. The code now travels in x-backthread-claim as well, from the same single read of the environment, so the ingress decides before it inflates anything. The payload keeps its copy: that is the published contract and the auditable record of what was sent. The ingress cross-checks the two and refuses a disagreement, so the body can only ever agree with what authorised the request. Sending both is required, not redundant — which is why the source guard pins ONE claimFromEnv call feeding both. Co-authored-by: Jevgeni Bogatyrjov <arpyousolutions@gmail.com>
PreviousNext
