fix(skills): publish gitnexus-plan artifacts on macOS without an interpreter (#2905) (#2922)

* fix(skills): anchor gitnexus-plan safe writer on macOS (#2905)

The safe generated-plan writer refused to run on anything but Linux.
`requireDescriptorAnchoring()` hard-gated `process.platform !== 'linux'`
because every name it resolves went through `/proc/self/fd/<fd>/<child>`,
and publication went through `renameat2(RENAME_NOREPLACE)`. macOS has
neither, so `write-plan` and `read-plan` failed on every input and
`snapshot` failed whenever a materialized path was absent.

Node cannot perform openat-style directory-relative resolution on macOS
at all: `node:fs` exposes no dir_fd parameter, and `fcntl(F_GETPATH)` is
a snapshot string that XNU reconstructs from the name cache, so using it
would reintroduce the exact race this helper exists to prevent. Python
does expose the *at() family via dir_fd, and macOS has renameatx_np with
RENAME_EXCL, so the anchoring borrows the interpreter the writer already
spawns for renameat2.

Anchoring now goes through a backend with two implementations. The Linux
one keeps the original expressions, flags, ordering and error strings.
The Darwin one runs each operation in the integrity-checked python3: it
re-walks the chain from the repository root with O_DIRECTORY|O_NOFOLLOW,
asserting the caller's recorded device, inode and mode at every level
before acting. A chain that fails that assertion reports a dedicated
anchoring errno and never ENOENT, so a moved parent cannot be read as an
absent file. Node holds an open descriptor on every chain element for the
anchor's lifetime, which pins the inodes so their numbers cannot be
recycled between spawns, and that coupling is re-checked on the way into
every request rather than left implicit.

A filesystem that answers ENOTSUP to RENAME_EXCL is a refusal, never a
fallback to a replacing rename. Every other platform is still refused.

The suite had silently skipped on every non-Linux runner, so it is now
gated on linux-or-darwin and registered in the cross-platform test list,
which puts it on the macos-latest CI matrix.

Disclosed rather than papered over: operations that must hand Node a file
descriptor are anchored in the helper and then opened lexically with
O_NOFOLLOW and identity-compared. A racer can force a mismatch, which
aborts, or land on the inode the anchored walk already found, which is
harmless. A perfect ABA inside that window is impossible on Linux and
detected in all but its narrowest form on macOS. The reference doc says
so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* test(skills): normalize the anchoring-gate fixture repo on Windows

The two capability-gate tests are the only ones in this file that run on
Windows, and both failed there: `createBaseRepo` returned the path
`os.tmpdir()` gave it, which on Windows is the 8.3 short form
(C:\Users\RUNNER~1\...). `assertRepository` compares fs.realpathSync of
the caller's path against the realpath of `git rev-parse --show-toplevel`,
and plain realpathSync does not expand short names while git always
reports the long form, so the helper rejected its own fixture with
"--repo must be the Git worktree root" before either platform gate was
reached.

Resolve the fixture with the native resolver, which returns the canonical
long path. No-op on platforms where the two already agree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* test(skills): skip the darwin backend gate on Windows

Spoofing process.platform does not spoof fs.constants. Windows Node
defines no O_DIRECTORY, so a darwin-spoofed run there refuses at the
anchoring-flag check and returns that message instead of ever reaching
the python3-backend branch the test exists to cover.

Skip it on win32 rather than loosening the regex, which would also let a
macOS run pass on the wrong message. The sibling test still asserts the
Windows refusal on Windows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* refactor(skills): tighten the macOS anchoring backend

Quality pass over the Darwin backend. No behaviour change was intended
on the success paths; the guarantees are the same or stronger.

Structural:

- openChildRead now proves identity inside the backend instead of by
  comment. It was returning a raw descriptor from a lexical open, with
  the "callers always compare against the preceding anchored stat"
  invariant enforced across four call sites in prose — and since the
  Linux predicate is a literal `return true`, a fifth caller that forgot
  would have been an unanchored open on macOS that Linux CI could not
  see. It routes through darwinAdoptAnchoredFile, which already did
  open-then-compare-then-close-on-mismatch for createChild.

- recordAnchoredAbsence shares one prefix walk per snapshot instead of
  re-walking from the repository root for every absent cited path. With
  three absent paths under a three-deep prefix that is 12 helper spawns
  down to 6 and 12 retained descriptors down to 4. citedPaths is
  caller-supplied and unbounded, so the descriptor retention was the
  real problem; the cache is now the sole close owner. This does change
  Linux descriptor lifetime — prefixes stay open for the snapshot rather
  than only the tail, deduplicated across paths.

- assertRepository and the sibling realpath comparisons use
  realpathSync.native. Windows hands back 8.3 short names that plain
  realpathSync preserves while git reports the long form, so `snapshot`,
  which is not platform-gated, could reject a worktree root by quoting
  that same directory back at the user. The fixture workaround that
  papered over this for the new gate tests is gone.

Efficiency, all measured at ~13.5ms per helper spawn:

- consume the identity mkdir already computed rather than re-stat it
- act on renameNoReplace's return value rather than spending two stats
  re-deriving what it already reported
- drop a duplicate anchored stat taken twice in a row in movePathToVault
- import ctypes only where it is used; 19 of 20 spawns never touch it

Simplification: pins folded into the descriptors the handle already
carried, an unreachable refreshAnchorTail branch and the dead
darwinHardenedOpen mode parameter removed, the four copies of the spawn
options collapsed, the spawn-and-parse shared between the probe and the
request path, the unreachable launch-path fallback and a redundant memo
deleted, and the helper's dispatch made a real elif chain with leaf name
and mode validated at one chokepoint rather than per operation.

The two chain encodings were left alone deliberately: merging them would
have grown triple fields on Linux for no Linux benefit and changed the
Linux validatePlanParent comparison. The double re-stamp that motivated
the merge is contained in one named helper with the hazard documented.

Rejected candidate interpreters now say which dir_fd operations were
missing instead of producing a generic refusal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* refactor(skills): publish plans with link(2) and drop the interpreter

The macOS backend spawned python3 for two jobs: openat-style resolution,
which Node cannot do, and a no-replace rename. Only the first is actually
unavoidable, and the second was carrying the whole dependency.

link(2) is a no-replace publish. It is atomic, it fails EEXIST when the
destination name is taken, and it refuses a symlinked destination without
following it — the same guarantee renameat2(RENAME_NOREPLACE) and
renameatx_np(RENAME_EXCL) give, reachable from plain fs.linkSync. The
published file is the same inode as the verified temporary, so the
downstream identity checks hold by construction rather than by argument.

That removes the interpreter from Linux entirely, since /proc already did
the resolving there, and it removes ctypes, libSystem, RENAME_EXCL and the
ENOTSUP handling from macOS. Deleted with them: the trusted-executable
validation, the held-descriptor exec and its two-tier probe, the capability
probe, the JSON request protocol, and both embedded Python programs. The
helper drops from 3047 to 2327 lines.

macOS keeps the part that genuinely cannot be done in Node, and now does it
without a subprocess: a lexical O_NOFOLLOW walk that holds an open
descriptor on every directory in the chain and re-proves the chain either
side of every step. Pinning is load-bearing — an open descriptor keeps its
inode number from being recycled, which is what makes the recorded
identities trustworthy across steps.

The guarantees are no longer symmetric and the docs say so plainly.
/dev/fd/<fd> is a devfs node, not a magic link: opening it works, resolving
through it does not, open("/dev/fd/<fd>/child") returns ENOENT and realpath
returns /dev/fd/<fd> — measured on macOS 26 rather than inferred. So Linux
makes a parent swap impossible while macOS detects one and aborts.

Also fixes the writer on 9p mounts, where renameat2(RENAME_NOREPLACE)
returns EINVAL and publication failed every time; link(2) succeeds there.

Tests 174 -> 154: dropped 29 fixtures that drove the deleted Python program
directly, added coverage for the link publish, for a macOS parent swap
caught through the pinned chain, and for a spoofed-darwin round trip that
asserts no /proc path reaches the hooks, which the portable backend now
makes runnable on Linux CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* fix(skills): drop O_NOFOLLOW_ANY, guard trailing slashes, handle link edge cases

macOS CI rejected our hardened directory open with EINVAL on 30 tests. The
flag O_NOFOLLOW_ANY was ORed into every open on the theory that XNU ignores
unrecognized open bits, so it would be inert where unsupported. That theory
is wrong, at least combined with O_DIRECTORY. The Python design never hit
it because the walk ran inside the interpreter; once Node did the opening,
every Darwin directory open went through it.

Removed rather than probed. The per-component O_NOFOLLOW walk is what
delivers the guarantee, and cap-std — the closest reference implementation
of this problem — has not adopted O_NOFOLLOW_ANY either. A fixture now pins
the exact flags of every directory open under a spoofed darwin, so the next
failure names the flag instead of printing a stack trace. With the flag
gone the two backends' directory open became identical, so it is no longer
a platform concern at all.

Three findings from researching the prior art, all now covered:

Trailing slashes. CVE-2026-39822 escaped Go's os.Root because
open(fd, path, O_NOFOLLOW) follows symlinks when the path ends in "/". It
reproduces here: with docs a symlink, opening "docs" is ENOTDIR but "docs/"
succeeds into the attacker's directory, and path.join preserves the slash.
We were safe only by construction, and only for repo-derived names — the
generated temporary and vault artifact names never passed through the
validator. The guard now sits at anchoredChild, the single place a name
becomes a path, so it holds for every caller.

link() can lie on NFS. Per link(2) BUGS, the return code may be wrong if
the server creates the link then dies before replying; open(2) NOTES gives
the remedy, which is to stat the source and treat a link count of 2 as
success. Implemented, with the man-page reasoning in the comment so it is
not later removed as paranoia.

Filesystems without hard links now fail loudly. EPERM, ENOTSUP and EMLINK
say so and refuse to fall back to a replacing rename. Git falls back and
accepts losing collision detection because its objects are content
addressed; that reasoning does not transfer to a named plan destination.

Durability was already correct — the temporary is fsynced before
publication and the parent directory immediately after — but the comment
now records why the parent fsync is required for link as it was for rename,
and the honest limitation that fsync is not a write barrier on macOS while
F_FULLFSYNC, which Node cannot reach, is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

* refactor(skills): shrink the anchoring seam and fix two CI breaks

Four quality reviews over the pure-Node writer. Two real breaks, one
drift that had already happened, and a seam that was sized for a design
we deleted.

The macOS round-trip fixture asserted that every observed path started
with join(repo, 'docs/plans'). Reproduced on Linux by handing the helper
a repo reached through a symlink, which is the shape macOS gives us via
/var to /private/var: assertRepository realpaths the repo, so the handle
builds paths from the resolved form while the fixture holds the form it
passed in, and the prefix can never match. The assertion now proves the
same thing without depending on the prefix — a lexical resolution always
contains a docs/plans segment and /proc/self/fd/<fd>/<name> never does.

Two publish fixtures sat in the capability-gate describe, the one block
deliberately not skipped on unsupported platforms, while this PR added
the file to the Windows matrix. They test link(2), not the gate, so they
moved to SAFE_WRITE_FIXTURES.

validatePlanParent restated verifyLexicalChain's loop without the
try/catch that converts ENOENT and ENOTDIR into the parity message, so a
raw errno could escape a function with a dozen call sites. It was masked
on Darwin only because parentStillResolves catches first. It now calls
the helpers, which also removes a second full chain walk per call there.

openVerifiedFile adds O_NONBLOCK so a FIFO swapped in at the target name
cannot wedge the process on open, and only Darwin was calling it. The
operations are now shared, so Linux gets it by construction rather than
by a per-backend decision.

The backend is five methods rather than ten. The platform difference is
two things — how a name becomes a path, and what guard wraps an
operation — so the five operations became shared functions over a
`verified` hook that is run() on Linux and the pinned-plus-lexical
sandwich on Darwin. openChildRead always runs the identity adoption, so
that proof is structural rather than a comment about what callers must
remember. Selecting the backend is a registry that throws on an unknown
platform instead of a ternary defaulting to Linux, which surfaced seven
dead bindings that ran before the capability gate and made win32 report
the registry error instead of the refusal.

Snapshot capture no longer re-walks a prefix per record: 36,018 lstats
to 6,384 and 162ms to 130ms on 2,000 dirty files across 100 directories,
with a byte-identical global_dirty_digest. Absence anchoring is now
bounded at 4096 pinned directories and refuses rather than evicting,
because closing a cached descriptor would break the pinned chain of a
guard already recorded — the inode-recycling hole the pins exist to
close.

The test suite no longer cache-busts its imports. That existed for the
memoized python3 descriptor, the file's only mutable module binding,
which is gone; the suite drops from 10.0s to 8.2s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NtGZN6YSNU8chALSnKYHBp

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Magyar 2026-08-11 12:24:53 +01:00 committed by GitHub
parent 135bcae03d
commit 740f0a4e57
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 4713 additions and 2430 deletions

View file

@ -124,12 +124,17 @@ phase that needs them.
statement-level claims (never reconstructs fake edges).
- No GitNexus at all → fallback mode: targeted grep/read exploration, findings
labelled **source-derived**, with a recommendation to index.
- Reading or publishing a plan requires Linux `/proc/self/fd`, `O_DIRECTORY`,
and `O_NOFOLLOW`; publication also requires a validated absolute Python 3
PATH candidate with libc `renameat2(RENAME_NOREPLACE)` support, a
writable target repository, and a shared filesystem for the plan and
Git-admin vault. The writer fails closed when those guarantees are
unavailable; it never redirects the plan elsewhere.
- Reading or publishing a plan requires `O_DIRECTORY` and `O_NOFOLLOW`, plus
`/proc/self/fd` on Linux; every other platform is refused. No interpreter is
spawned and no native code is loaded. Publication is `link(2)`, which fails
rather than replaces when the destination name is taken. Linux resolves every
name against a held descriptor, so a parent swapped mid-write cannot redirect
the operation; macOS has no equivalent path and instead pins each directory
with an open descriptor and re-proves the chain either side of every step,
which detects such a swap and aborts. Publishing also needs a writable target
repository and a shared filesystem for the plan and Git-admin vault. The
writer fails closed when those guarantees are unavailable; it never redirects
the plan elsewhere.
## Limitations

View file

@ -98,8 +98,11 @@ excluded.
## Safe existing-plan read contract
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
`read-plan` fails closed unless the host platform can resolve names against a
held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and
`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is
refused outright — an unverified read is not a degraded read, it is a different,
racy operation. It resolves the exact Git top-level, opens the
repository root and every plan parent as held no-follow directory descriptors,
rejects missing, symlink, non-directory, and escaping parents, and opens the
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt.
## Safe generated-plan write contract
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
available. Python may live in `/usr/local`, a Nix profile, or another absolute
PATH directory, but the helper accepts only a resolved executable and
containing directory owned by root or the current user and not writable by
group/other. The resolved executable is opened without following links and
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
The writer fails closed unless the host platform offers `O_DIRECTORY` and
`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads
no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when
the destination name is taken, and refuses a symlinked destination without
following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and
`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every
supported platform. The temporary name is unlinked once the link succeeds; the
published file is the same inode the writer created and verified, so every
identity check downstream holds by construction. A link that succeeds followed
by an unlink that fails leaves the plan published and is reported as success,
because it is one. The plan parent and the
repository's Git-admin directory must also share a filesystem. It resolves
the target repository's exact Git top-level, opens that root and every
destination parent as held no-follow directory descriptors, creates missing
@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final
parent descriptor and keeps its no-follow descriptor open. It writes and
flushes the bytes, binds the temporary name to the opened inode, and hashes the
open file before publication. Immediately before publication it revalidates
the parent and the temporary path, inode, size, and digest. Publication uses an
atomic no-replace move relative to the held directory descriptor. Initial mode
therefore cannot overwrite a destination that appears after the absent check.
the parent and the temporary path, inode, size, and digest. Publication links
the temporary name to the destination relative to the held directory
descriptor, which fails rather than replaces if the destination is taken.
Initial mode therefore cannot overwrite a destination that appears after the
absent check.
The writer then flushes the directory and revalidates the committed path by
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
path-bound fd, and performing a second descriptor-anchored path identity check
after hashing. A detected mutation or replacement aborts instead of accepting
mixed-era output.
### Linux anchors, macOS verifies
The two platforms reach the same destination by different proofs, and the
difference is real enough to state rather than smooth over.
On Linux every name resolves through `/proc/self/fd/<fd>/<child>`, a magic link
the kernel resolves against the inode the descriptor already holds. The names
above it are never re-walked, so an attacker who renames a parent between the
check and the use cannot redirect the operation. The race is impossible, not
merely detected.
macOS has no such path. `/dev/fd/<fd>` is a devfs node, not a magic link: it can
be opened, but nothing can be resolved through it. `open("/dev/fd/<fd>/child")`
returns `ENOENT`, and `realpath` of it returns `/dev/fd/<fd>` rather than the
directory's path — measured on macOS 26, not inferred. Node exposes no `openat`,
no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names
lexically with `O_NOFOLLOW` at every component, holds an open descriptor on
every directory in the chain for the whole operation, and proves before *and*
after each step that the chain still names exactly the inodes it is holding.
Holding the descriptors is what makes the recorded inode numbers trustworthy:
an open descriptor pins its inode, so a freed number cannot be recycled beneath
the walk.
What that buys is detection rather than prevention. A parent swapped inside the
window between a check and its use is caught by the check that follows, and the
operation aborts having written nothing — but on Linux it could not have
happened at all. No published byte escapes verification on either platform.
`--replace` accepts only a pre-existing regular file and is reserved for
Deepen; without it, accidental overwrite is rejected. It also requires the
exact canonical `generated_plan_path` and `plan_digest` from the same session's

File diff suppressed because it is too large Load diff

View file

@ -98,8 +98,11 @@ excluded.
## Safe existing-plan read contract
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
`read-plan` fails closed unless the host platform can resolve names against a
held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and
`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is
refused outright — an unverified read is not a degraded read, it is a different,
racy operation. It resolves the exact Git top-level, opens the
repository root and every plan parent as held no-follow directory descriptors,
rejects missing, symlink, non-directory, and escaping parents, and opens the
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt.
## Safe generated-plan write contract
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
available. Python may live in `/usr/local`, a Nix profile, or another absolute
PATH directory, but the helper accepts only a resolved executable and
containing directory owned by root or the current user and not writable by
group/other. The resolved executable is opened without following links and
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
The writer fails closed unless the host platform offers `O_DIRECTORY` and
`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads
no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when
the destination name is taken, and refuses a symlinked destination without
following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and
`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every
supported platform. The temporary name is unlinked once the link succeeds; the
published file is the same inode the writer created and verified, so every
identity check downstream holds by construction. A link that succeeds followed
by an unlink that fails leaves the plan published and is reported as success,
because it is one. The plan parent and the
repository's Git-admin directory must also share a filesystem. It resolves
the target repository's exact Git top-level, opens that root and every
destination parent as held no-follow directory descriptors, creates missing
@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final
parent descriptor and keeps its no-follow descriptor open. It writes and
flushes the bytes, binds the temporary name to the opened inode, and hashes the
open file before publication. Immediately before publication it revalidates
the parent and the temporary path, inode, size, and digest. Publication uses an
atomic no-replace move relative to the held directory descriptor. Initial mode
therefore cannot overwrite a destination that appears after the absent check.
the parent and the temporary path, inode, size, and digest. Publication links
the temporary name to the destination relative to the held directory
descriptor, which fails rather than replaces if the destination is taken.
Initial mode therefore cannot overwrite a destination that appears after the
absent check.
The writer then flushes the directory and revalidates the committed path by
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
path-bound fd, and performing a second descriptor-anchored path identity check
after hashing. A detected mutation or replacement aborts instead of accepting
mixed-era output.
### Linux anchors, macOS verifies
The two platforms reach the same destination by different proofs, and the
difference is real enough to state rather than smooth over.
On Linux every name resolves through `/proc/self/fd/<fd>/<child>`, a magic link
the kernel resolves against the inode the descriptor already holds. The names
above it are never re-walked, so an attacker who renames a parent between the
check and the use cannot redirect the operation. The race is impossible, not
merely detected.
macOS has no such path. `/dev/fd/<fd>` is a devfs node, not a magic link: it can
be opened, but nothing can be resolved through it. `open("/dev/fd/<fd>/child")`
returns `ENOENT`, and `realpath` of it returns `/dev/fd/<fd>` rather than the
directory's path — measured on macOS 26, not inferred. Node exposes no `openat`,
no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names
lexically with `O_NOFOLLOW` at every component, holds an open descriptor on
every directory in the chain for the whole operation, and proves before *and*
after each step that the chain still names exactly the inodes it is holding.
Holding the descriptors is what makes the recorded inode numbers trustworthy:
an open descriptor pins its inode, so a freed number cannot be recycled beneath
the walk.
What that buys is detection rather than prevention. A parent swapped inside the
window between a check and its use is caught by the check that follows, and the
operation aborts having written nothing — but on Linux it could not have
happened at all. No published byte escapes verification on either platform.
`--replace` accepts only a pre-existing regular file and is reserved for
Deepen; without it, accidental overwrite is rejected. It also requires the
exact canonical `generated_plan_path` and `plan_digest` from the same session's

File diff suppressed because it is too large Load diff

View file

@ -124,12 +124,17 @@ phase that needs them.
statement-level claims (never reconstructs fake edges).
- No GitNexus at all → fallback mode: targeted grep/read exploration, findings
labelled **source-derived**, with a recommendation to index.
- Reading or publishing a plan requires Linux `/proc/self/fd`, `O_DIRECTORY`,
and `O_NOFOLLOW`; publication also requires a validated absolute Python 3
PATH candidate with libc `renameat2(RENAME_NOREPLACE)` support, a
writable target repository, and a shared filesystem for the plan and
Git-admin vault. The writer fails closed when those guarantees are
unavailable; it never redirects the plan elsewhere.
- Reading or publishing a plan requires `O_DIRECTORY` and `O_NOFOLLOW`, plus
`/proc/self/fd` on Linux; every other platform is refused. No interpreter is
spawned and no native code is loaded. Publication is `link(2)`, which fails
rather than replaces when the destination name is taken. Linux resolves every
name against a held descriptor, so a parent swapped mid-write cannot redirect
the operation; macOS has no equivalent path and instead pins each directory
with an open descriptor and re-proves the chain either side of every step,
which detects such a swap and aborts. Publishing also needs a writable target
repository and a shared filesystem for the plan and Git-admin vault. The
writer fails closed when those guarantees are unavailable; it never redirects
the plan elsewhere.
## Limitations

View file

@ -98,8 +98,11 @@ excluded.
## Safe existing-plan read contract
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
`read-plan` fails closed unless the host platform can resolve names against a
held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and
`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is
refused outright — an unverified read is not a degraded read, it is a different,
racy operation. It resolves the exact Git top-level, opens the
repository root and every plan parent as held no-follow directory descriptors,
rejects missing, symlink, non-directory, and escaping parents, and opens the
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt.
## Safe generated-plan write contract
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
available. Python may live in `/usr/local`, a Nix profile, or another absolute
PATH directory, but the helper accepts only a resolved executable and
containing directory owned by root or the current user and not writable by
group/other. The resolved executable is opened without following links and
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
The writer fails closed unless the host platform offers `O_DIRECTORY` and
`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads
no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when
the destination name is taken, and refuses a symlinked destination without
following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and
`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every
supported platform. The temporary name is unlinked once the link succeeds; the
published file is the same inode the writer created and verified, so every
identity check downstream holds by construction. A link that succeeds followed
by an unlink that fails leaves the plan published and is reported as success,
because it is one. The plan parent and the
repository's Git-admin directory must also share a filesystem. It resolves
the target repository's exact Git top-level, opens that root and every
destination parent as held no-follow directory descriptors, creates missing
@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final
parent descriptor and keeps its no-follow descriptor open. It writes and
flushes the bytes, binds the temporary name to the opened inode, and hashes the
open file before publication. Immediately before publication it revalidates
the parent and the temporary path, inode, size, and digest. Publication uses an
atomic no-replace move relative to the held directory descriptor. Initial mode
therefore cannot overwrite a destination that appears after the absent check.
the parent and the temporary path, inode, size, and digest. Publication links
the temporary name to the destination relative to the held directory
descriptor, which fails rather than replaces if the destination is taken.
Initial mode therefore cannot overwrite a destination that appears after the
absent check.
The writer then flushes the directory and revalidates the committed path by
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
path-bound fd, and performing a second descriptor-anchored path identity check
after hashing. A detected mutation or replacement aborts instead of accepting
mixed-era output.
### Linux anchors, macOS verifies
The two platforms reach the same destination by different proofs, and the
difference is real enough to state rather than smooth over.
On Linux every name resolves through `/proc/self/fd/<fd>/<child>`, a magic link
the kernel resolves against the inode the descriptor already holds. The names
above it are never re-walked, so an attacker who renames a parent between the
check and the use cannot redirect the operation. The race is impossible, not
merely detected.
macOS has no such path. `/dev/fd/<fd>` is a devfs node, not a magic link: it can
be opened, but nothing can be resolved through it. `open("/dev/fd/<fd>/child")`
returns `ENOENT`, and `realpath` of it returns `/dev/fd/<fd>` rather than the
directory's path — measured on macOS 26, not inferred. Node exposes no `openat`,
no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names
lexically with `O_NOFOLLOW` at every component, holds an open descriptor on
every directory in the chain for the whole operation, and proves before *and*
after each step that the chain still names exactly the inodes it is holding.
Holding the descriptors is what makes the recorded inode numbers trustworthy:
an open descriptor pins its inode, so a freed number cannot be recycled beneath
the walk.
What that buys is detection rather than prevention. A parent swapped inside the
window between a check and its use is caught by the check that follows, and the
operation aborts having written nothing — but on Linux it could not have
happened at all. No published byte escapes verification on either platform.
`--replace` accepts only a pre-existing regular file and is reserved for
Deepen; without it, accidental overwrite is rejected. It also requires the
exact canonical `generated_plan_path` and `plan_digest` from the same session's

View file

@ -98,8 +98,11 @@ excluded.
## Safe existing-plan read contract
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
`read-plan` fails closed unless the host platform can resolve names against a
held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and
`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is
refused outright — an unverified read is not a degraded read, it is a different,
racy operation. It resolves the exact Git top-level, opens the
repository root and every plan parent as held no-follow directory descriptors,
rejects missing, symlink, non-directory, and escaping parents, and opens the
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt.
## Safe generated-plan write contract
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
available. Python may live in `/usr/local`, a Nix profile, or another absolute
PATH directory, but the helper accepts only a resolved executable and
containing directory owned by root or the current user and not writable by
group/other. The resolved executable is opened without following links and
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
The writer fails closed unless the host platform offers `O_DIRECTORY` and
`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads
no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when
the destination name is taken, and refuses a symlinked destination without
following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and
`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every
supported platform. The temporary name is unlinked once the link succeeds; the
published file is the same inode the writer created and verified, so every
identity check downstream holds by construction. A link that succeeds followed
by an unlink that fails leaves the plan published and is reported as success,
because it is one. The plan parent and the
repository's Git-admin directory must also share a filesystem. It resolves
the target repository's exact Git top-level, opens that root and every
destination parent as held no-follow directory descriptors, creates missing
@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final
parent descriptor and keeps its no-follow descriptor open. It writes and
flushes the bytes, binds the temporary name to the opened inode, and hashes the
open file before publication. Immediately before publication it revalidates
the parent and the temporary path, inode, size, and digest. Publication uses an
atomic no-replace move relative to the held directory descriptor. Initial mode
therefore cannot overwrite a destination that appears after the absent check.
the parent and the temporary path, inode, size, and digest. Publication links
the temporary name to the destination relative to the held directory
descriptor, which fails rather than replaces if the destination is taken.
Initial mode therefore cannot overwrite a destination that appears after the
absent check.
The writer then flushes the directory and revalidates the committed path by
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
path-bound fd, and performing a second descriptor-anchored path identity check
after hashing. A detected mutation or replacement aborts instead of accepting
mixed-era output.
### Linux anchors, macOS verifies
The two platforms reach the same destination by different proofs, and the
difference is real enough to state rather than smooth over.
On Linux every name resolves through `/proc/self/fd/<fd>/<child>`, a magic link
the kernel resolves against the inode the descriptor already holds. The names
above it are never re-walked, so an attacker who renames a parent between the
check and the use cannot redirect the operation. The race is impossible, not
merely detected.
macOS has no such path. `/dev/fd/<fd>` is a devfs node, not a magic link: it can
be opened, but nothing can be resolved through it. `open("/dev/fd/<fd>/child")`
returns `ENOENT`, and `realpath` of it returns `/dev/fd/<fd>` rather than the
directory's path — measured on macOS 26, not inferred. Node exposes no `openat`,
no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names
lexically with `O_NOFOLLOW` at every component, holds an open descriptor on
every directory in the chain for the whole operation, and proves before *and*
after each step that the chain still names exactly the inodes it is holding.
Holding the descriptors is what makes the recorded inode numbers trustworthy:
an open descriptor pins its inode, so a freed number cannot be recycled beneath
the walk.
What that buys is detection rather than prevention. A parent swapped inside the
window between a check and its use is caught by the check that follows, and the
operation aborts having written nothing — but on Linux it could not have
happened at all. No published byte escapes verification on either platform.
`--replace` accepts only a pre-existing regular file and is reserved for
Deepen; without it, accidental overwrite is rejected. It also requires the
exact canonical `generated_plan_path` and `plan_digest` from the same session's

View file

@ -36,6 +36,16 @@ const PLATFORM_LOGIC = [
// must exercise the Windows backslash branch, so run it on the OS matrix (#2394).
'test/unit/cli-entry.test.ts',
'test/unit/platform-capabilities.test.ts',
// The gitnexus-plan safe writer resolves every name through a per-platform
// backend: Linux anchors through /proc/self/fd, macOS resolves lexically and
// verifies each step against descriptors it holds open. Publication is link(2)
// on both. #2905 shipped the Darwin backend after the suite had silently
// skipped on every non-Linux runner, so this file must run on the OS matrix or
// the macOS half is unverified by construction — and the flag, trailing-
// separator and hard-link fixtures assert kernel behaviour that only a real
// Darwin kernel can confirm. Windows is refused by the capability gate; the
// suite asserts that refusal rather than skipping it.
'test/unit/evidence-provenance-helper.test.ts',
// Windows drive-letter case variance in the analyzer runner-identity path
// fields (#2668): normalizeAnalyzerRootPath is a POSIX no-op, so the
// "identity path fields are normalizer-stable" fixpoint guard only bites on

View file

@ -124,12 +124,17 @@ phase that needs them.
statement-level claims (never reconstructs fake edges).
- No GitNexus at all → fallback mode: targeted grep/read exploration, findings
labelled **source-derived**, with a recommendation to index.
- Reading or publishing a plan requires Linux `/proc/self/fd`, `O_DIRECTORY`,
and `O_NOFOLLOW`; publication also requires a validated absolute Python 3
PATH candidate with libc `renameat2(RENAME_NOREPLACE)` support, a
writable target repository, and a shared filesystem for the plan and
Git-admin vault. The writer fails closed when those guarantees are
unavailable; it never redirects the plan elsewhere.
- Reading or publishing a plan requires `O_DIRECTORY` and `O_NOFOLLOW`, plus
`/proc/self/fd` on Linux; every other platform is refused. No interpreter is
spawned and no native code is loaded. Publication is `link(2)`, which fails
rather than replaces when the destination name is taken. Linux resolves every
name against a held descriptor, so a parent swapped mid-write cannot redirect
the operation; macOS has no equivalent path and instead pins each directory
with an open descriptor and re-proves the chain either side of every step,
which detects such a swap and aborts. Publishing also needs a writable target
repository and a shared filesystem for the plan and Git-admin vault. The
writer fails closed when those guarantees are unavailable; it never redirects
the plan elsewhere.
## Limitations

View file

@ -98,8 +98,11 @@ excluded.
## Safe existing-plan read contract
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
`read-plan` fails closed unless the host platform can resolve names against a
held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and
`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is
refused outright — an unverified read is not a degraded read, it is a different,
racy operation. It resolves the exact Git top-level, opens the
repository root and every plan parent as held no-follow directory descriptors,
rejects missing, symlink, non-directory, and escaping parents, and opens the
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt.
## Safe generated-plan write contract
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
available. Python may live in `/usr/local`, a Nix profile, or another absolute
PATH directory, but the helper accepts only a resolved executable and
containing directory owned by root or the current user and not writable by
group/other. The resolved executable is opened without following links and
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
The writer fails closed unless the host platform offers `O_DIRECTORY` and
`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads
no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when
the destination name is taken, and refuses a symlinked destination without
following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and
`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every
supported platform. The temporary name is unlinked once the link succeeds; the
published file is the same inode the writer created and verified, so every
identity check downstream holds by construction. A link that succeeds followed
by an unlink that fails leaves the plan published and is reported as success,
because it is one. The plan parent and the
repository's Git-admin directory must also share a filesystem. It resolves
the target repository's exact Git top-level, opens that root and every
destination parent as held no-follow directory descriptors, creates missing
@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final
parent descriptor and keeps its no-follow descriptor open. It writes and
flushes the bytes, binds the temporary name to the opened inode, and hashes the
open file before publication. Immediately before publication it revalidates
the parent and the temporary path, inode, size, and digest. Publication uses an
atomic no-replace move relative to the held directory descriptor. Initial mode
therefore cannot overwrite a destination that appears after the absent check.
the parent and the temporary path, inode, size, and digest. Publication links
the temporary name to the destination relative to the held directory
descriptor, which fails rather than replaces if the destination is taken.
Initial mode therefore cannot overwrite a destination that appears after the
absent check.
The writer then flushes the directory and revalidates the committed path by
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
path-bound fd, and performing a second descriptor-anchored path identity check
after hashing. A detected mutation or replacement aborts instead of accepting
mixed-era output.
### Linux anchors, macOS verifies
The two platforms reach the same destination by different proofs, and the
difference is real enough to state rather than smooth over.
On Linux every name resolves through `/proc/self/fd/<fd>/<child>`, a magic link
the kernel resolves against the inode the descriptor already holds. The names
above it are never re-walked, so an attacker who renames a parent between the
check and the use cannot redirect the operation. The race is impossible, not
merely detected.
macOS has no such path. `/dev/fd/<fd>` is a devfs node, not a magic link: it can
be opened, but nothing can be resolved through it. `open("/dev/fd/<fd>/child")`
returns `ENOENT`, and `realpath` of it returns `/dev/fd/<fd>` rather than the
directory's path — measured on macOS 26, not inferred. Node exposes no `openat`,
no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names
lexically with `O_NOFOLLOW` at every component, holds an open descriptor on
every directory in the chain for the whole operation, and proves before *and*
after each step that the chain still names exactly the inodes it is holding.
Holding the descriptors is what makes the recorded inode numbers trustworthy:
an open descriptor pins its inode, so a freed number cannot be recycled beneath
the walk.
What that buys is detection rather than prevention. A parent swapped inside the
window between a check and its use is caught by the check that follows, and the
operation aborts having written nothing — but on Linux it could not have
happened at all. No published byte escapes verification on either platform.
`--replace` accepts only a pre-existing regular file and is reserved for
Deepen; without it, accidental overwrite is rejected. It also requires the
exact canonical `generated_plan_path` and `plan_digest` from the same session's

File diff suppressed because it is too large Load diff

View file

@ -98,8 +98,11 @@ excluded.
## Safe existing-plan read contract
`read-plan` fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`, and
`O_NOFOLLOW` are available. It resolves the exact Git top-level, opens the
`read-plan` fails closed unless the host platform can resolve names against a
held directory descriptor: Linux `/proc/self/fd` with `O_DIRECTORY` and
`O_NOFOLLOW`, or macOS `O_DIRECTORY`/`O_NOFOLLOW`. Every other platform is
refused outright — an unverified read is not a degraded read, it is a different,
racy operation. It resolves the exact Git top-level, opens the
repository root and every plan parent as held no-follow directory descriptors,
rejects missing, symlink, non-directory, and escaping parents, and opens the
leaf with `O_NOFOLLOW`. It reads at most 16 MiB from that held file descriptor,
@ -109,13 +112,17 @@ Neither Deepen nor work may parse bytes obtained before or outside this receipt.
## Safe generated-plan write contract
The writer fails closed unless Linux `/proc/self/fd`, `O_DIRECTORY`,
`O_NOFOLLOW`, and Python 3 with libc `renameat2(RENAME_NOREPLACE)` support are
available. Python may live in `/usr/local`, a Nix profile, or another absolute
PATH directory, but the helper accepts only a resolved executable and
containing directory owned by root or the current user and not writable by
group/other. The resolved executable is opened without following links and
invoked through that held descriptor. Relative PATH entries are ignored. The plan parent and the
The writer fails closed unless the host platform offers `O_DIRECTORY` and
`O_NOFOLLOW`, plus `/proc/self/fd` on Linux. It spawns no interpreter and loads
no native code: publication is `link(2)`, which is atomic, fails `EEXIST` when
the destination name is taken, and refuses a symlinked destination without
following it — the same no-replace guarantee `renameat2(RENAME_NOREPLACE)` and
`renameatx_np(RENAME_EXCL)` provide, available through `fs.linkSync` on every
supported platform. The temporary name is unlinked once the link succeeds; the
published file is the same inode the writer created and verified, so every
identity check downstream holds by construction. A link that succeeds followed
by an unlink that fails leaves the plan published and is reported as success,
because it is one. The plan parent and the
repository's Git-admin directory must also share a filesystem. It resolves
the target repository's exact Git top-level, opens that root and every
destination parent as held no-follow directory descriptors, creates missing
@ -128,15 +135,45 @@ The writer creates a random exclusive temporary file relative to the held final
parent descriptor and keeps its no-follow descriptor open. It writes and
flushes the bytes, binds the temporary name to the opened inode, and hashes the
open file before publication. Immediately before publication it revalidates
the parent and the temporary path, inode, size, and digest. Publication uses an
atomic no-replace move relative to the held directory descriptor. Initial mode
therefore cannot overwrite a destination that appears after the absent check.
the parent and the temporary path, inode, size, and digest. Publication links
the temporary name to the destination relative to the held directory
descriptor, which fails rather than replaces if the destination is taken.
Initial mode therefore cannot overwrite a destination that appears after the
absent check.
The writer then flushes the directory and revalidates the committed path by
opening it with `O_NOFOLLOW`, hashing both the original temporary fd and the
path-bound fd, and performing a second descriptor-anchored path identity check
after hashing. A detected mutation or replacement aborts instead of accepting
mixed-era output.
### Linux anchors, macOS verifies
The two platforms reach the same destination by different proofs, and the
difference is real enough to state rather than smooth over.
On Linux every name resolves through `/proc/self/fd/<fd>/<child>`, a magic link
the kernel resolves against the inode the descriptor already holds. The names
above it are never re-walked, so an attacker who renames a parent between the
check and the use cannot redirect the operation. The race is impossible, not
merely detected.
macOS has no such path. `/dev/fd/<fd>` is a devfs node, not a magic link: it can
be opened, but nothing can be resolved through it. `open("/dev/fd/<fd>/child")`
returns `ENOENT`, and `realpath` of it returns `/dev/fd/<fd>` rather than the
directory's path — measured on macOS 26, not inferred. Node exposes no `openat`,
no `dir_fd` parameter, and no FFI, so on macOS the writer resolves names
lexically with `O_NOFOLLOW` at every component, holds an open descriptor on
every directory in the chain for the whole operation, and proves before *and*
after each step that the chain still names exactly the inodes it is holding.
Holding the descriptors is what makes the recorded inode numbers trustworthy:
an open descriptor pins its inode, so a freed number cannot be recycled beneath
the walk.
What that buys is detection rather than prevention. A parent swapped inside the
window between a check and its use is caught by the check that follows, and the
operation aborts having written nothing — but on Linux it could not have
happened at all. No published byte escapes verification on either platform.
`--replace` accepts only a pre-existing regular file and is reserved for
Deepen; without it, accidental overwrite is rejected. It also requires the
exact canonical `generated_plan_path` and `plan_digest` from the same session's

File diff suppressed because it is too large Load diff

View file

@ -109,8 +109,12 @@ describe('gitnexus-plan evidence provenance contract', () => {
/absent cited path[\s\S]*descriptor[\s\S]*checked both before and after/i,
],
[
'nonstandard trusted Python paths are supported',
/Python may live in[\s\S]*Nix[\s\S]*absolute\s+PATH/i,
'publication is a no-replace link, not an interpreter',
/spawns no interpreter[\s\S]*link\(2\)[\s\S]*fails `EEXIST`/i,
],
[
'the macOS guarantee is stated, not smoothed over',
/Linux anchors, macOS verifies[\s\S]*detection rather than prevention/i,
],
]);
});

View file

@ -94,7 +94,6 @@ type EvidenceHelper = {
replace: boolean;
}): void;
afterPublication?(committed: { fd: number; finalPath: string }): void;
afterRename?(committed: { fd: number; finalPath: string }): void;
afterFinalOpen?(committed: { fd: number; finalPath: string }): void;
};
}): {
@ -205,10 +204,13 @@ function createFixture(): string {
return repo;
}
// Cached, not cache-busted. The query-string bust existed for `let
// atomicMoverPath`, the memoized python3 descriptor, which is gone: the helper
// now has no module-level `let`/`var` at all and its module-level consts are
// immutable lookup tables. Platform selection reads process.platform per call,
// so a spoofed-platform fixture and a native one can share one instance.
async function importHelper(file: string): Promise<EvidenceHelper> {
return (await import(
`${pathToFileURL(file).href}?test=${Date.now()}-${Math.random()}`
)) as EvidenceHelper;
return (await import(pathToFileURL(file).href)) as EvidenceHelper;
}
const REAL_GIT_FIXTURES = process.platform === 'win32' ? describe.skip : describe;
@ -667,7 +669,46 @@ REAL_GIT_FIXTURES('evidence provenance v2 helper', () => {
});
});
const SAFE_WRITE_FIXTURES = process.platform === 'linux' ? describe : describe.skip;
// The safe writer runs on the two platforms that can tie a name to an inode:
// Linux anchors through /proc/self/fd, macOS verifies against pinned descriptors.
// Everything else is refused, which the "neither backend" fixture below asserts
// from any host.
const SUPPORTED_WRITE_PLATFORMS = new Set(['linux', 'darwin']);
const SAFE_WRITE_FIXTURES = SUPPORTED_WRITE_PLATFORMS.has(process.platform)
? describe
: describe.skip;
function inodeIdentity(stat: fs.BigIntStats): string {
return `${stat.dev}:${stat.ino}`;
}
function inodeIdentityOf(target: string): string {
return inodeIdentity(fs.statSync(target, { bigint: true }));
}
// Both open-flag fixtures want the same thing: record something about every
// fs.openSync the helper issues, then delegate.
function recordOpens<T>(collect: (target: fs.PathLike, flags: number) => T, into: T[]) {
const realOpen = fs.openSync.bind(fs) as typeof fs.openSync;
return vi.spyOn(fs, 'openSync').mockImplementation(((
target: fs.PathLike,
flags: number,
mode?: fs.Mode,
) => {
into.push(collect(target, flags));
return realOpen(target, flags, mode);
}) as typeof fs.openSync);
}
// Both platforms expose the process's own descriptors as a readable directory;
// only the path differs. Chosen from the real platform, never a spoofed one.
const OPEN_DESCRIPTOR_DIRECTORY = process.platform === 'darwin' ? '/dev/fd' : '/proc/self/fd';
// O_CLOEXEC is POSIX-only and absent from the Node typings, so the helper reads
// it as `?? 0`; the fixtures below have to compose the same value the same way.
const O_CLOEXEC = (fs.constants as typeof fs.constants & { O_CLOEXEC?: number }).O_CLOEXEC ?? 0;
const VERIFIED_DIRECTORY_FLAGS =
fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW | O_CLOEXEC;
SAFE_WRITE_FIXTURES('generated-plan safe writer', () => {
it('reads an exact descriptor-anchored plan receipt through both API and CLI', async () => {
@ -1392,8 +1433,11 @@ SAFE_WRITE_FIXTURES('generated-plan safe writer', () => {
const realFsync = fs.fsyncSync.bind(fs);
const spy = vi.spyOn(fs, 'fsyncSync').mockImplementation((fd) => {
try {
const resolved = fs.realpathSync(`/proc/self/fd/${fd}`);
if (fs.fstatSync(fd).isDirectory()) fsyncedDirectories.push(resolved);
// A descriptor cannot be turned back into a path on macOS — /proc/self/fd
// has no equivalent and F_GETPATH is unreachable from Node — so a synced
// directory is identified by the inode it refers to, on both platforms.
const stat = fs.fstatSync(fd, { bigint: true });
fsyncedDirectories.push(...(stat.isDirectory() ? [inodeIdentity(stat)] : []));
} catch {
// The production call below owns any real fsync error.
}
@ -1422,16 +1466,16 @@ SAFE_WRITE_FIXTURES('generated-plan safe writer', () => {
gitDirectory,
path.join(gitDirectory, 'gitnexus-plan-backups'),
]) {
expect(fsyncedDirectories).toContain(fs.realpathSync(durableDirectory));
expect(fsyncedDirectories).toContain(inodeIdentityOf(durableDirectory));
}
expect(
fsyncedDirectories.filter(
(entry) => entry === fs.realpathSync(path.join(repo, 'docs/plans')),
(entry) => entry === inodeIdentityOf(path.join(repo, 'docs/plans')),
).length,
).toBeGreaterThanOrEqual(2);
expect(
fsyncedDirectories.filter(
(entry) => entry === fs.realpathSync(path.join(gitDirectory, 'gitnexus-plan-backups')),
(entry) => entry === inodeIdentityOf(path.join(gitDirectory, 'gitnexus-plan-backups')),
).length,
).toBeGreaterThanOrEqual(2);
} finally {
@ -1440,44 +1484,340 @@ SAFE_WRITE_FIXTURES('generated-plan safe writer', () => {
}
});
it('uses a validated absolute python3 candidate from a nonstandard PATH directory', async () => {
const repo = createBaseRepo('gitnexus-plan-python-path-');
const toolsDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-safe-tools-'));
const marker = path.join(toolsDirectory, 'python-used');
const originalPath = process.env.PATH;
const originalMarker = process.env.GITNEXUS_TEST_PYTHON_MARKER;
it('refuses a filesystem without hard links instead of replacing the destination', async () => {
const repo = createBaseRepo('gitnexus-plan-nolinks-');
try {
fs.chmodSync(toolsDirectory, 0o700);
const pythonLookup = spawnSync('sh', ['-c', 'command -v python3'], { encoding: 'utf8' });
const gitLookup = spawnSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' });
expect(pythonLookup.status).toBe(0);
expect(gitLookup.status).toBe(0);
const python = fs.realpathSync(pythonLookup.stdout.trim());
const gitExecutable = fs.realpathSync(gitLookup.stdout.trim());
const wrapper = path.join(toolsDirectory, 'python3');
fs.writeFileSync(
wrapper,
`#!/bin/sh\n: > "$GITNEXUS_TEST_PYTHON_MARKER"\nexec "${python}" "$@"\n`,
{ mode: 0o700 },
);
fs.symlinkSync(gitExecutable, path.join(toolsDirectory, 'git'));
process.env.PATH = toolsDirectory;
process.env.GITNEXUS_TEST_PYTHON_MARKER = marker;
const planner = await importHelper(PLAN_HELPER);
const spy = vi.spyOn(fs, 'linkSync').mockImplementation(() => {
throw Object.assign(new Error('EPERM: operation not permitted, link'), { code: 'EPERM' });
});
let message = '';
try {
planner.writePlanSafely({
repo,
generatedPlanPath: SAFE_PLAN_PATH,
contents: '# intended\n',
});
} catch (error) {
message = (error as Error).message;
} finally {
spy.mockRestore();
}
expect(message).toMatch(
/requires hard links, which this filesystem refused \(EPERM\); refusing to fall back to a replacing rename/,
);
// Nothing was published, and the intended bytes are still recoverable.
expect(fs.existsSync(path.join(repo, SAFE_PLAN_PATH))).toBe(false);
expect(artifactContents(repo, message, 'intended-plan')).toBe('# intended\n');
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
it('publishes by link: same inode, refusing a taken or symlinked destination', async () => {
const repo = createBaseRepo('gitnexus-plan-publish-');
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-plan-publish-outside-'));
try {
const planner = await importHelper(PLAN_HELPER);
// The published plan is the very inode whose bytes were fsynced, which is
// what lets validateCommittedPlan compare against the temporary file.
let temporaryIdentity = '';
planner.writePlanSafely({
repo,
generatedPlanPath: SAFE_PLAN_PATH,
contents: '# nonstandard python\n',
contents: '# published\n',
testHooks: {
beforePublication({ tempPath }) {
temporaryIdentity = inodeIdentity(fs.statSync(tempPath, { bigint: true }));
},
},
});
expect(fs.existsSync(marker)).toBe(true);
const publishedStat = fs.statSync(path.join(repo, SAFE_PLAN_PATH), { bigint: true });
expect(inodeIdentity(publishedStat)).toBe(temporaryIdentity);
// link() plus unlink() leaves exactly one name for that inode.
expect(Number(publishedStat.nlink)).toBe(1);
expect(
fs.readdirSync(path.join(repo, 'docs/plans')).filter((entry) => entry.endsWith('.tmp')),
).toEqual([]);
// A destination that is a symlink is refused without following it, so the
// symlink's target is never clobbered.
write(outside, 'victim.md', '# victim\n');
fs.symlinkSync(path.join(outside, 'victim.md'), path.join(repo, ALTERNATE_SAFE_PLAN_PATH));
expect(() =>
planner.writePlanSafely({
repo,
generatedPlanPath: ALTERNATE_SAFE_PLAN_PATH,
contents: '# blocked\n',
}),
).toThrow(/regular file, never a symlink|already exists/);
expect(fs.readFileSync(path.join(outside, 'victim.md'), 'utf8')).toBe('# victim\n');
} finally {
if (originalPath === undefined) delete process.env.PATH;
else process.env.PATH = originalPath;
if (originalMarker === undefined) delete process.env.GITNEXUS_TEST_PYTHON_MARKER;
else process.env.GITNEXUS_TEST_PYTHON_MARKER = originalMarker;
fs.rmSync(repo, { recursive: true, force: true });
fs.rmSync(toolsDirectory, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
});
// The capability gate is the one part of the safe writer that must be observable
// from every platform, including the ones it refuses, so it is asserted outside
// the descriptor-anchored suite rather than skipped along with it.
describe('generated-plan anchoring capability gate', () => {
// The gate reads process.platform at call time, so each of these fixtures runs
// the real helper against a spoofed platform and always puts the descriptor back.
function withPlatform(name: string, run: () => void): void {
const original = Object.getOwnPropertyDescriptor(process, 'platform') as PropertyDescriptor;
Object.defineProperty(process, 'platform', { value: name, configurable: true });
try {
run();
} finally {
Object.defineProperty(process, 'platform', original);
}
}
it('refuses every platform that has neither backend', async () => {
const repo = createBaseRepo('gitnexus-plan-platform-gate-');
try {
const planner = await importHelper(PLAN_HELPER);
withPlatform('win32', () => {
expect(() => planner.readPlanSafely({ repo, generatedPlanPath: SAFE_PLAN_PATH })).toThrow(
/Linux \/proc\/self\/fd or macOS O_DIRECTORY\/O_NOFOLLOW; win32 offers neither, so refusing an unanchored write/,
);
expect(() =>
planner.writePlanSafely({
repo,
generatedPlanPath: SAFE_PLAN_PATH,
contents: '# blocked\n',
}),
).toThrow(/win32 offers neither, so refusing an unanchored write/);
});
expect(fs.existsSync(path.join(repo, SAFE_PLAN_PATH))).toBe(false);
expect(fs.existsSync(path.join(repo, 'docs'))).toBe(false);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
// Spoofing process.platform does not spoof fs.constants, and Windows Node
// defines no O_DIRECTORY — so a darwin-spoofed run there refuses at the flag
// check and can never reach the backend these fixtures cover. The test above
// still asserts the Windows refusal on Windows.
const DARWIN_BACKEND = process.platform === 'win32' ? it.skip : it;
// The Darwin backend needs no interpreter and no /proc, so it is entirely
// portable: spoofing the platform exercises the real macOS code path on this
// host rather than leaving it unrun until a macOS runner picks it up.
DARWIN_BACKEND('admits macOS on the directory flags alone, naming no interpreter', async () => {
const repo = createBaseRepo('gitnexus-plan-darwin-gate-');
try {
const planner = await importHelper(PLAN_HELPER);
const observedPaths: string[] = [];
withPlatform('darwin', () => {
expect(
planner.writePlanSafely({
repo,
generatedPlanPath: SAFE_PLAN_PATH,
contents: '# verified\n',
testHooks: {
beforePublication({ finalPath, tempPath }) {
observedPaths.push(finalPath, tempPath);
},
},
}),
).toEqual({ generated_plan_path: SAFE_PLAN_PATH, bytes_written: 11 });
// Proof that the Darwin backend was actually selected rather than the
// Linux one quietly succeeding: Linux resolves children through
// /proc/self/fd/<fd>/<name>, Darwin resolves them lexically.
expect(observedPaths).toHaveLength(2);
// Deliberately prefix-independent: assertRepository realpaths the repo,
// so on macOS expectedPath is /private/var/... while the fixture holds
// the /var/... form it passed in. What distinguishes the backends is the
// shape, not the prefix — a lexical resolution keeps the docs/plans
// segments, and /proc/self/fd/<fd>/<name> has neither.
expect(observedPaths.filter((entry) => entry.startsWith('/proc/'))).toEqual([]);
expect(
observedPaths.filter(
(entry) => !entry.includes(`${path.sep}docs${path.sep}plans${path.sep}`),
),
).toEqual([]);
expect(planner.readPlanSafely({ repo, generatedPlanPath: SAFE_PLAN_PATH })).toMatchObject({
plan_bytes_base64: Buffer.from('# verified\n').toString('base64'),
});
});
expect(fs.readFileSync(path.join(repo, SAFE_PLAN_PATH), 'utf8')).toBe('# verified\n');
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
DARWIN_BACKEND('detects a macOS parent swap through the pinned chain', async () => {
const repo = createBaseRepo('gitnexus-plan-darwin-swap-');
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-plan-darwin-outside-'));
try {
fs.mkdirSync(path.join(repo, 'docs/plans'), { recursive: true });
const planner = await importHelper(PLAN_HELPER);
withPlatform('darwin', () => {
expect(() =>
planner.writePlanSafely({
repo,
generatedPlanPath: SAFE_PLAN_PATH,
contents: '# blocked\n',
testHooks: {
afterParentOpen() {
fs.renameSync(path.join(repo, 'docs/plans'), path.join(repo, 'docs/plans-moved'));
fs.symlinkSync(outside, path.join(repo, 'docs/plans'));
},
},
}),
).toThrow(/moved or was replaced|no longer matches/);
});
expect(fs.existsSync(path.join(outside, path.posix.basename(SAFE_PLAN_PATH)))).toBe(false);
expect(
fs.existsSync(path.join(repo, 'docs/plans-moved', path.posix.basename(SAFE_PLAN_PATH))),
).toBe(false);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
// The pins stop a freed inode number from being recycled by a replacement
// directory that would otherwise reproduce a recorded identity exactly. That
// attack is unchanged by dropping the interpreter, so the coverage stays.
DARWIN_BACKEND('pins every directory of an absence chain and releases them all', async () => {
const repo = createBaseRepo('gitnexus-plan-darwin-pins-');
try {
write(repo, '.gitignore', 'a/\n');
git(repo, ['add', '.gitignore']);
git(repo, ['commit', '--quiet', '-m', 'ignore']);
fs.mkdirSync(path.join(repo, 'a', 'b', 'c'), { recursive: true });
const chain = [repo, path.join(repo, 'a'), path.join(repo, 'a/b'), path.join(repo, 'a/b/c')];
const openDirectoryInodes = (): Set<string> =>
new Set(
fs
.readdirSync(OPEN_DESCRIPTOR_DIRECTORY)
.map((entry) => {
try {
const stat = fs.fstatSync(Number(entry), { bigint: true });
return stat.isDirectory() ? inodeIdentity(stat) : null;
} catch {
// descriptor closed while enumerating
return null;
}
})
.filter((identity): identity is string => identity !== null),
);
const planner = await importHelper(PLAN_HELPER);
const baseline = openDirectoryInodes();
let pinned = new Set<string>();
withPlatform('darwin', () => {
planner.snapshotEvidence({
repo,
generatedPlanPath: SAFE_PLAN_PATH,
citedPaths: ['a/b/c/one.txt', 'a/b/c/two.txt'],
testHooks: {
afterFirstGuardPass() {
pinned = openDirectoryInodes();
},
},
});
});
expect(chain.filter((directory) => !pinned.has(inodeIdentityOf(directory)))).toEqual([]);
const released = openDirectoryInodes();
expect(
chain.filter(
(directory) =>
released.has(inodeIdentityOf(directory)) && !baseline.has(inodeIdentityOf(directory)),
),
).toEqual([]);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
// macOS rejected O_NOFOLLOW_ANY combined with O_DIRECTORY outright (EINVAL),
// which took out every directory open on Darwin. The flags are pinned here so
// the next "this bit is probably harmless" idea fails on Linux first.
DARWIN_BACKEND('opens directories with exactly the four verified flags', async () => {
const repo = createBaseRepo('gitnexus-plan-flags-');
const openFlags: number[] = [];
try {
const planner = await importHelper(PLAN_HELPER);
const spy = recordOpens((_target, flags) => flags, openFlags);
try {
withPlatform('darwin', () => {
planner.writePlanSafely({
repo,
generatedPlanPath: SAFE_PLAN_PATH,
contents: '# flags\n',
});
});
} finally {
spy.mockRestore();
}
const directoryOpens = openFlags.filter(
(flags) => (flags & fs.constants.O_DIRECTORY) === fs.constants.O_DIRECTORY,
);
expect(directoryOpens).not.toHaveLength(0);
expect(directoryOpens.filter((flags) => flags !== VERIFIED_DIRECTORY_FLAGS)).toEqual([]);
// O_NOFOLLOW_ANY must not reappear on any open, directory or file.
expect(openFlags.filter((flags) => (flags & 0x20000000) !== 0)).toEqual([]);
// Every no-follow open keeps O_NOFOLLOW; nothing silently drops it.
expect(openFlags.filter((flags) => (flags & fs.constants.O_NOFOLLOW) === 0)).toEqual([]);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
}
});
// CVE-2026-39822 / golang/go#79005: open(path, O_NOFOLLOW) follows a symlink
// when path ends in "/", which is how os.Root escaped its own root.
DARWIN_BACKEND('never resolves a component carrying a trailing separator', async () => {
const repo = createBaseRepo('gitnexus-plan-slash-');
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-plan-slash-outside-'));
const openedPaths: string[] = [];
try {
fs.writeFileSync(path.join(outside, 'loot.md'), 'loot\n');
const decoy = path.join(repo, 'decoy');
fs.symlinkSync(outside, decoy);
// The trap is real on this host: the same open is refused without the
// slash and follows straight into the attacker's directory with it.
expect(() => fs.closeSync(fs.openSync(decoy, VERIFIED_DIRECTORY_FLAGS))).toThrow();
const followed = fs.openSync(`${decoy}/`, VERIFIED_DIRECTORY_FLAGS);
try {
expect(fs.readdirSync(`${decoy}/`)).toContain('loot.md');
} finally {
fs.closeSync(followed);
}
const planner = await importHelper(PLAN_HELPER);
const spy = recordOpens((target) => String(target), openedPaths);
try {
withPlatform('darwin', () => {
planner.writePlanSafely({
repo,
generatedPlanPath: SAFE_PLAN_PATH,
contents: '# no trailing slash\n',
});
});
} finally {
spy.mockRestore();
}
expect(openedPaths).not.toHaveLength(0);
expect(openedPaths.filter((entry) => entry !== '/' && entry.endsWith('/'))).toEqual([]);
// And a plan path that smuggles one in is refused before any open.
expect(() =>
planner.writePlanSafely({
repo,
generatedPlanPath: `${SAFE_PLAN_PATH}/`,
contents: '# blocked\n',
}),
).toThrow(/normalized repo-relative path|restricted to docs\/plans/);
} finally {
fs.rmSync(repo, { recursive: true, force: true });
fs.rmSync(outside, { recursive: true, force: true });
}
});
});