v0.5.0 — guard bypasses, resource leaks, and every untrue doc claim
Closes the rest of the audit: the guard bypasses, the resource leaks, and every documentation claim that was not true.
Four ways past the guard
The path checks are lexical — they can only refuse a path that literally appears in the command line. So these read every file in the repo while naming none of them:
grep -rs . . git log -p
git show HEAD:.env find . -exec cat {} +
tar cf - . | base64 git cat-file -p HEAD:.env
Bash(grep *), Bash(git log *) and Bash(git show *) are all on the allowlist. In a sandbox repo, grep -rs . . printed the AWS key and git log -p printed it out of history.
The fix is scope-aware rather than a flat ban: a recursive search is refused only when the directory it would walk actually holds never-touch files, and git log -p only when the repo actually tracks one. grep -rn TODO src still works, because refusing it would buy no safety and push the session toward worse tools.
Three more:
- A redirect without a space defeated the tokenizer.
<and>were not split characters, socat<.env,cat 0<.envandecho pwned>.envproduced one token that matched no glob and no path. The spaced forms were caught all along, which is what made the gap easy to miss — and the write form destroyed a gitignored.envoutright. - Bash could read outside the repository.
checkFilehard-denied an escaping path from the first release;checkBashonly glob-tested them, socat /etc/passwd,cat ~/.ssh/id_rsaandcat ~/.aws/credentialswent through the one tool that can ignore the prompt's "work only inside the repository".~never resolved either, so it was missed twice over. - Bracket globs matched nothing in either direction.
expandGlobcompiled them with the never-touch matcher, which escapes[and], so.[e]nvmatched neither the file on disk nor the.envrule. The guard allowed it and the shell then expanded it.
And reportDir was validated only as "a non-empty string" while being interpolated into the Windows guard-hook command line, where arguments are quoted but not escaped — a .phantomrc reading .phantom/reports" & calc & " ran calc on every PreToolUse hook.
Resource leaks
- Ring-buffer memory was driven by the number of writes, not their size. Every write became its own Buffer and the index array grew to twice the live chunk count before compacting, so an unbuffered child — a spinner, a progress bar, anything calling
write()per character — cost about 130 bytes of heap per retained byte: 30 MB of heap and 255 MB of RSS for a 256 KB tail. Small writes are coalesced into blocks now; the same workload costs 0.3 bytes per byte. The shipped memory test only pushed 64 KB chunks, so it never saw this. - The tail could start mid-character. Eviction cuts on a byte boundary, so decoding produced U+FFFD at the head of the tail for any non-ASCII output, and that flowed into the crash JSON, the prompt and the report.
- Every successful
phantom npm run devrecovery orphaned a process tree.spawnSync'stimeoutsignals the direct child only — npm, not the server it started — which kept running and kept its port, so the user's next realnpm run devfailed withEADDRINUSEand nothing pointed at phantom. This is the documented success path: "still running counts as fixed" means the timeout fires every time a long-lived command is repaired. git clean -fddestroyed untracked work. Phantom tells you your branch is untouched, which invites you to keep working, and there is one working tree — so a file you create during a run looks exactly like one the session created, and Ctrl+C deleted it with no reflog to recover from. Untracked files are rescued into a stash first now, and phantom prints the command that brings them back.
Also fixed
- The status line claimed to be fixing crashes phantom had refused:
announceCrashran before the refusal check, so a declined crash still logged an event, and since no recovery event follows, the status line showed "fixing …" for twenty minutes while the plugin briefed Claude to look for a fix branch that was never created. - Reports were written non-atomically, so a reader could catch a half-written file and a Ctrl+C inside the write destroyed the post-mortem.
- Banner borders were misaligned wherever an emoji appeared — which is every status phantom prints — because width was measured in UTF-16 code units rather than terminal columns.
- Every run printed its outcome twice: the banner, then the identical sentence again underneath it.
Added
keepReports (default 50). Nothing pruned .phantom/crashes/ or .phantom/reports/, and each crash JSON carries the full context up to ringBufferBytes. The newest are kept; 0 keeps everything. Note this deletes files by default.
A pack-smoke CI job on all three platforms, pinned to Node 18.0.0 rather than the floating 18 that resolves to 18.20.x. It packs the tarball, installs it into a path with a space and a non-ASCII character, asserts every runtime file dependency resolves from what files actually shipped, and runs phantom inside a git worktree. Those are the classes the existing matrix structurally cannot catch — and they are exactly how plugin/ went missing from the tarball for four releases.
Documentation
Every claim below was false, stale, or unverifiable against the code:
- "the redacted last 256 KiB of output" — the session sees the last 200 lines capped at 24 KiB. 256 KiB is what phantom retains. Off by roughly 10× on the tool's central promise.
- The example banner and post-mortem were fabricated — wrong header, wrong row order,
merge/discardinstead ofaccept/reject, and a three-column table phantom has never emitted, all labelled "fromexamples/crash-demo". Both replaced with output captured from a real run. - The README contradicted itself on the macOS notification permission;
.phantomrcis read from the working directory first and not merged with the root one;--max-turnsis a third hard cap nothing mentioned; exit codes 126, 127, 129 and 143 were missing; the allow and deny tables were both incomplete while reading as exhaustive. - The site still advertised a Windows guard hole that 0.3.5 closed, and printed a
.phantomrcpanel that omittedverifyCommandand showedtestCommandat a default it does not have.
Notes
325 → 339 tests, every fix mutation-checked against the 0.4.0 behaviour, and the suite run under FORCE_COLOR=1 as well. CI green on all 15 jobs.
Full changelog: v0.4.0...v0.5.0