Skip to content

v0.5.0 — guard bypasses, resource leaks, and every untrue doc claim

Choose a tag to compare

@waazy-w waazy-w released this 22 Aug 05:14
· 25 commits to main since this release

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, so cat<.env, cat 0<.env and echo pwned>.env produced 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 .env outright.
  • Bash could read outside the repository. checkFile hard-denied an escaping path from the first release; checkBash only glob-tested them, so cat /etc/passwd, cat ~/.ssh/id_rsa and cat ~/.aws/credentials went 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. expandGlob compiled them with the never-touch matcher, which escapes [ and ], so .[e]nv matched neither the file on disk nor the .env rule. 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 dev recovery orphaned a process tree. spawnSync's timeout signals the direct child only — npm, not the server it started — which kept running and kept its port, so the user's next real npm run dev failed with EADDRINUSE and 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 -fd destroyed 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: announceCrash ran 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/discard instead of accept/reject, and a three-column table phantom has never emitted, all labelled "from examples/crash-demo". Both replaced with output captured from a real run.
  • The README contradicted itself on the macOS notification permission; .phantomrc is read from the working directory first and not merged with the root one; --max-turns is 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 .phantomrc panel that omitted verifyCommand and showed testCommand at 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