Accepted limits

Five rounds of adversarial review hardened the CLI’s filesystem writes. What remains is a short list of races Node gives no way to close.

They are written down here because a limit nobody recorded reads as a limit nobody saw.

The threat model, first

What the kit does guarantee is compatibility. The limits below mostly concern the config directory, where secrets live on your machine and pithy secrets writes them.

Every item below needs an attacker who can already write to your project directory.

Somebody with that access can add a postinstall to package.json, drop a .git/hooks/pre-commit, or edit pithy.config.ts. Each is arbitrary code execution as you, immediately, with no race to win.

Against that adversary, hardening a generated .dev.vars against a planted symlink is a lock on one door of a building with no walls.

So these are accepted, not ignored. The bar they fail is relevance, not severity.

The path walk lstats each component, checks the owner, then reads it as a link. Two syscalls, one name, and the name is the attacker’s to change between them.

Closing it needs openat(2) against a directory descriptor, which Node does not expose for a relative walk.

The inode check → rename window

The inode at the temp name is compared against the file the bytes actually went into, immediately before the rename.

rename is path-based and Node ships no renameat, so a swap landing inside that gap still wins.

The check turns a reliable escape into a narrow race. It does not close it.

A pre-positioned file the kit already owns

An existing target’s mode is carried onto the temp file, so your deliberate 0600 survives a write — and a mode read off a file another uid owns is refused.

Somebody with directory-write can still move a world-readable file the kit owns into place.

What holds is the ceiling: a mode wider than the one the caller asked for is never adopted. The residue is exotic.

Bind mounts and hard-linked directories

Recursive deletes resolve the target with realpath and require the result under the resolved project root.

realpath resolves symlinks and nothing else. A bind mount at apps/ presents a path that resolves inside the project while the bytes live somewhere else entirely — and the delete follows it.

Detecting one means reading the mount table, which is platform-specific and not portable.

Windows has no uid model

The rule separating your symlink from a planted one is ownership — symlink(2) stamps the creating uid, and only root may change it afterwards.

Windows has nothing to compare, so the ownership check returns early there and a mode is adopted from anyone.

The mode ceiling still holds, and that is the half that stops a widening. The source says so at both sites.

An editor the kit has nothing to say about

pithy secrets edit refuses a known GUI editor given no wait flag — and names the flag to add.

There is no portable way to ask a program whether it will block, so an editor absent from that table is spawned and waited on, and one that returns immediately hands back a draft nobody has typed into yet.

What that costs is bounded on purpose. An untouched draft is byte-identical to the file it came from, so the run reports unchanged and writes nothing. It never overwrites the edit still in progress in your window — your later save lands in the draft file beside the real one.

pithy secrets edit is not the command that loses an edit.

Windows runs the editor through a shell

Because every GUI editor there is a .cmd shim and Node refuses to spawn one directly.

Anything carrying a space is quoted — a " cannot appear in a Windows path, so there is nothing left to escape. What reaches that shell unquoted is your own $EDITOR, which is what $EDITOR means on every other tool too.

Not verified on a Windows host. The branch is covered by an injected spawn, which is what a POSIX CI can prove.

The tripwires read source text

Three build-failing rules guard the filesystem writes. The import half is decidable and hard to evade; the rest is a heuristic over source text, and its blind spots are on record beside it:

  • A path arriving as a function parameter is invisible.
  • A fresh name appended to a cleared path reads as cleared.
  • A path that never names apps or capabilities in its own module is never examined.
  • The recursive-delete rule matches call text, so an aliased import evades it.
  • A mutating call’s created path is read from its first argument — and symlink(target, path) creates its second.

Neither obvious alternative is the right home. A lint rule matching expressions cannot express a conjunction of a module fact and a call, and TypeScript 7 ships no standalone parser — its AST arrives only through a compiler server that needs a resolved project and says in its own specifier that it is unstable.

ESC