feat(client): writeSkills materialization and the abuse matrix - #33
Merged
Merged
Conversation
This was referenced Aug 25, 2026
XieX
force-pushed
the
xie/skills-05-write-fs
branch
from
August 28, 2026 18:02
c475dfe to
f14d4a8
Compare
XieX
force-pushed
the
xie/skills-05-write-fs
branch
2 times, most recently
from
August 30, 2026 08:29
3245b63 to
4f4df2a
Compare
XieX
force-pushed
the
xie/skills-05-write-fs
branch
from
August 31, 2026 18:10
4f4df2a to
065c470
Compare
XieX
force-pushed
the
xie/skills-05-write-fs
branch
from
September 14, 2026 20:13
065c470 to
a03e219
Compare
XieX
marked this pull request as ready for review
September 14, 2026 20:35
The highest-blast-radius layer in the feature: this writes LaunchDarkly-delivered
content to a customer's disk. It lands with the tests that prove its defenses
rather than after them, because several of those defenses are the kind that a
passing functional suite would not notice going missing.
`writeSkills(skills, root, options?)` reconciles `<root>/<key>/SKILL.md` against
a `.launchdarkly-skills.json` manifest and reports every outcome in a
`ReconcileReport` — nothing is silent. It accepts `Skill` values,
`SkillReference` values, and bare key strings. `timeout` is in **seconds**,
defaulting to 10, matching the Python signature rather than the usual TypeScript
`timeoutMs` instinct.
The manifest format is byte-level identical across languages: same filename,
same sorted-key ASCII-escaped serialization. A polyglot fleet reconciling one
directory has to produce the same bytes from either language, so the filename,
the format, and the exported constants are a cross-language breaking change to
alter. It is written through the same atomic path as a skill file.
It fails closed, and the checks are not negotiable. `keyRejectionReason` and
`unsafePathReason` are shared by the write and the prune paths precisely so the
two cannot disagree about which paths this SDK may destroy. Keys are
re-validated locally against the same anchored pattern, with a tighter
path-component bound than the data model's, since no mainstream filesystem
accepts a 256-byte component. Content is hash-verified again immediately before
the write, because a `Skill` can be constructed by a caller and not just by an
accessor. Symlinked roots, directories, and targets are refused. A destructive
operation is permitted only on a path the manifest lists under a matching key,
and a corrupt manifest suppresses every destructive action.
Two things the fail-closed posture needs in order not to fail *badly*.
A partial reconcile heals itself. The manifest is written atomically, once,
last, so a process killed after a skill file lands but before that write leaves
the file at a managed path with no entry — which is exactly what clobber
protection treats as a customer-placed file, so every later reconcile refuses it
and the skill is wedged until a human intervenes. Boot-time execution under a
10-second budget makes that window realistic. So a colliding file whose on-disk
sha256 equals the resolved content hash is **adopted**: recorded in the manifest
and reported `skipped_current`, the existing action kind whose documented
meaning — the bytes on disk already are the resolved content — is precisely this
case. It cannot weaken the guarantee, because the only bytes ever adopted are
bytes LaunchDarkly resolved; differing unmanaged bytes are still refused,
untouched. Adoption does make the file prunable later, which is correct: a
subsequent prune then removes content LaunchDarkly delivered, exactly what would
have happened had the crash not occurred.
And the read that comparison needs is now a guarded one. `readRegularFile` opens
with `O_NOFOLLOW | O_NONBLOCK`, `fstat`s the **handle** rather than the path, and
refuses anything that is not a regular file — because `readFile` on a FIFO with
no writer never returns, and a FIFO or a device node is neither a symlink nor a
directory, so `unsafePathReason` does not see one. That hazard predates adoption
on the managed path; adoption widens the read to genuinely foreign files, which
is what makes it a prerequisite rather than a cleanup. A read that fails is a
refusal and never a fall-through to the write: not knowing what is on disk is
the last state in which to overwrite it. No `O_BINARY`, unlike the Python twin —
Node performs no CRLF translation on a descriptor.
Two smaller additions in the same layer. Orphaned temp files are swept during the
reconcile: `atomicWrite` removes its own temp on any error it sees, but not after
a SIGKILL, and prune walks manifest entries only, so an orphan is invisible
forever *and* blocks the `rmdir` that would clean up an emptied skill directory.
The sweep is bounded on every axis — a key that passes `keyRejectionReason`, a
directory opened `O_NOFOLLOW` and pinned, only names matching the pattern
`safe-fs.ts` derives from its own generator (anchored at both ends, so it cannot
drift into matching something this SDK did not write), only regular files, and
through the same pinned-handle unlink the prune path uses. A failure is a
reported action, never a throw.
Second: the 22 Windows reserved device names — `con`, `prn`, `aux`, `nul`,
`com1`-`com9`, `lpt1`-`lpt9` — are rejected in `keyRejectionReason`, and
deliberately **not** in `isValidSkillKey`. `parseAiConfig` fails closed on a bad
`skills` entry, so a grammar-level rejection would invalidate the entire AI
Config — model, provider, instructions, tools — for a Linux or macOS customer
over a constraint that only exists on Windows; worse, `skillRefs` would silently
drop the reference, and a dropped reference lets prune delete the skill's on-disk
copy. "Fails to write on Windows" would become "gets deleted on Linux". The
255-byte path-component bound already lives in this layer for exactly that
reason. Unconditional rather than platform-gated, because a managed root written
by a Linux container and read from a Windows host is an ordinary deployment, and
because neither repo has a Windows CI runner — a `process.platform` branch would
be the one thing here no test could reach. The honest cost: a customer who
legitimately names a skill `aux` now gets a reported `error` where it would
previously have worked off Windows.
The abuse matrix here is what holds those rules in place:
- Path traversal, over seventeen hostile keys — parent traversal, absolute
paths, backslashes, drive letters, an NTFS alternate data stream, an embedded
null byte, uppercase, leading and trailing whitespace, a 257-byte key. Each
asserts that **no filesystem operation was attempted**, not merely that none
succeeded: the OS would reject several of these on its own, so a failed write
is not evidence of a defense.
- Symlink attacks on the root, on a skill directory, and on the target file,
plus the swap-race pair. Those two are skipped off `SUPPORTS_DIR_FD` and
written out in full, so they become live if Node ever grows the `*at()`
family; the capability probe is itself tested, so a probe that silently
reported "unsupported" could not also silently skip the cases that would have
caught it.
- Clobber protection: a file at a managed path with no manifest entry is never
overwritten unless its bytes already are the resolved content. Adoption,
byte-difference refusal, and a mixed run that adopts one skill and refuses
another are all covered, as is the follow-on reconcile being an ordinary no-op.
- Targets that must not be read as ordinary files: a FIFO (with an explicit test
timeout, so a regression fails rather than stalls CI), a directory standing
where `SKILL.md` belongs, and an unreadable file — which must refuse rather
than overwrite, with a reason distinguishable from the collision refusal.
- All 22 reserved device names, through both the write and the prune path, plus
the assertion that makes the layer choice load-bearing: `isValidSkillKey('con')`
is still `true` and `parseAiConfig` still accepts a config referencing it.
`com0` and `lpt0` are not reserved and still write.
- Corrupt manifest, over eight variants. Six are unparseable, which makes
"performed no destructive action" arithmetic rather than a defense. The two
`*_live_entries` variants are the ones that test the rule — corrupt only in
`manifestVersion`, with a valid entries map listing the managed path under a
matching key, so the implementation has everything it needs to overwrite and
to prune, and must refuse anyway.
Also lands the last of the package-root exports and the assertions that pin
their exact values, since a caller needs `MANIFEST_FILENAME` to gitignore the
manifest and `MAX_SKILL_CONTENT_BYTES` to pre-check content.
One residual is documented rather than implemented: the 255-byte bound is
per-component and does not bound the total path, so root + key + `/SKILL.md` can
still exceed Windows' 260-character `MAX_PATH` with a legal 200-character key.
The SDK cannot validate that — the root is the customer's.
Client package: 452 -> 555 tests, +2 skipped (the swap-race pair). typecheck and
biome clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`loadManifest` used a plain `readFile`, so a FIFO planted at `<root>/.launchdarkly-skills.json` never returned: the awaited promise never settled, a libuv threadpool thread stayed held, and the process could not exit. The `timeout` budget could not rescue it — it is cooperative, and `loadManifest` runs before the first deadline check. The manifest sits in the skills root, so it is reachable by exactly the swap `readRegularFile` was added in this branch to defend against on skill files; guarding one and not the other in the same directory was an inconsistency rather than a different risk tier. `O_NONBLOCK` makes the open return immediately and the handle `stat` refuses anything that is not a regular file, which lands in the existing corrupt-manifest branch: no destructive action, and the file left alone. `O_NOFOLLOW` also means a symlinked manifest is now refused rather than followed — deliberate, since the atomic rewrite already replaces a symlink at that path with a real file. Both cases are covered, the FIFO test with an explicit 5s timeout so a regression fails rather than stalling CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per review feedback, removes the Python SDK as the explanation for the
manifest's on-disk form while keeping the constraint it was there to
justify. Sorted keys and the non-ASCII `\uXXXX` escape are still
described as load-bearing — a root that more than one SDK reconciles
would otherwise churn the file's bytes on alternating runs — just stated
in terms of the shared on-disk form rather than json.dumps.
Also drops the development narrative from two doc comments ("Split out
of skills.ts", "Split out of writeOne"), which described past refactors
rather than the code as it stands, and the "unlike the Python twin" aside
on the missing O_BINARY.
No code changed. 582 tests pass, 2 skipped off SUPPORTS_DIR_FD as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX
force-pushed
the
xie/skills-05-write-fs
branch
from
September 16, 2026 18:26
60bb9e0 to
afbd8f0
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit afbd8f0. Configure here.
XieX
added a commit
that referenced
this pull request
Sep 16, 2026
Removes the internal-process references from this PR's comments and docs while keeping every technical constraint they carried: - The SEC-8985 ticket reference in the root-swap test header. The defect description around it is what a reader needs and stays as-is. - "the security review names", "reopening the security review", and "the security review asked for one; we declined" in agents.md and the prune test. The decisions and their reasoning stay; only the appeal to an internal process is gone. - The Windows bullet's reference to another repository's CI runner, and the Python contrast for where the racy floor sits. - Two re-introduced "Split out of ..." refactor narratives and the "retroactively lowers the priority" framing, which described project sequencing rather than the code. Note on the rebase onto the restacked #33: that PR's Bugbot fix routes the manifest read through `readRegularFile`, and this PR independently rewrote the same line to address the manifest via `root.address` with a plain `readFile`. The conflict resolution keeps both — `/proc/self/fd` addressing defeats a directory *swap*, but does nothing about a FIFO already sitting at the resolved path, so dropping `readRegularFile` would have restored the hang. The manifest FIFO test times out without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
andrewklatzke
approved these changes
Sep 16, 2026
Contributor
|
Worth taking a look at the bugbot on this one, might not be real but we should confirm at least |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Fifth of seven, and the heaviest — deliberately. This is the highest-blast-radius layer in the feature: it writes LaunchDarkly-delivered content to a customer's disk.
It lands with the tests that prove its defenses rather than after them, because several of those defenses are the kind a passing functional suite would not notice going missing. That's also why this PR is the largest in the stack; the remaining functional tests are split into the next one.
The API
writeSkills(skills, root, options?)reconciles<root>/<key>/SKILL.mdagainst a.launchdarkly-skills.jsonmanifest and reports every outcome in aReconcileReport— nothing is silent. It acceptsSkillvalues,SkillReferencevalues, and bare key strings.timeoutis in seconds, defaulting to10, matching the Python signature rather than the usual TypeScripttimeoutMsinstinct.Manifest format
Byte-level identical across languages: same filename, same sorted-key ASCII-escaped serialization. A polyglot fleet reconciling one directory has to produce the same bytes from either language, so the filename, the format, and the exported constants are a cross-language breaking change to alter. The manifest is written through the same atomic path as a skill file.
It fails closed, and these checks are not negotiable
keyRejectionReasonandunsafePathReasonare shared by the write and the prune paths precisely so the two cannot disagree about which paths this SDK may destroy.Skillcan be constructed by a caller and not just by an accessor. ASkillcarries bytes, so this pass hands them straight to the sharedverifiedBytesand they are hashed as-is — no encoding happens on the write path. The two-pass design and the integrity signal's property keys are unchanged (and identical to Python's).Partial reconciles heal themselves (review finding AV-3)
The manifest is written atomically, once, last. A process killed after a skill file lands but before that write leaves the file at a managed path with no manifest entry — exactly the condition clobber protection treats as a customer-placed file. So every later reconcile takes the refusal branch and that skill is permanently wedged until a human intervenes: the rule that protects customer files is what prevents self-healing. Boot-time execution under a 10-second budget makes the crash window realistic.
The fix is to adopt a colliding file when its on-disk sha256 equals the resolved content hash. The read-and-hash already existed in
writeOne, gated behindmanaged; the restructure just runs the hash comparison first. Three details:skipped_currentis reused rather than a newadoptedkind added. Its documented meaning — the bytes on disk already are the resolved content — is exactly this case. Adding a member to theReconcileActionKindstring-literal union would be a breaking change for any consumer with an exhaustiveswitch, and would drag feat(client): Agent Skills value types #29, six PRs down the stack, into this change.One caveat, stated plainly. Adoption also creates a manifest entry, so the adopted file becomes prunable on a later reconcile. That is correct and not a weakening: adoption only fires when the bytes are byte-identical to LaunchDarkly-resolved content, so a later prune removes content LaunchDarkly delivered anyway — exactly what would have happened had the crash not occurred. The guarantee that is preserved is the one that matters: differing unmanaged bytes are never overwritten.
The review also floats a write-intent journal as an alternative. It was assessed as over-engineered for the failure it prevents, and is deliberately not built.
Prerequisite: the regular-file read guard
writeOneread the on-disk file with a plainreadFile(target).unsafePathReasonchecks for a symlink, but a FIFO or a device node is neither a symlink nor a directory — andreadFileon a FIFO with no writer never returns, hanging the reconcile and the event loop with it. That hazard already existed on the managed path; adoption widens the read to unmanaged, genuinely foreign files, which is what makes fixing it a prerequisite rather than a cleanup.readRegularFileopens withO_RDONLY | O_NOFOLLOW | O_NONBLOCK,fstats the handle rather than the path, refuses anything that is not a regular file, always closes, and surfaces a failure as a refusal rather than a throw. It mirrors Python's_read_regular_file, including its placement inskills_fs.pyrather than in thesafe_fstwin. One deliberate difference: noO_BINARY, because Node performs no CRLF translation on a descriptor.Secondary: orphaned temp files are swept
atomicWriteremoves its own temp file on any error it sees — but not after a SIGKILL, and prune walks manifest entries only, so an orphan is invisible to the reconcile forever and blocks thermdirthat would clean up an emptied skill directory.The sweep runs during the reconcile, ahead of the prune, and is bounded on every axis: only inside
<root>/<key>/for a key that passeskeyRejectionReason; only names matching the temp pattern anchored at both ends, derived fromtempNamePatterninsafe-fs.tsrather than a second copy of the naming rule; only regular files, never following a symlink, and through the same pinned-handleunlinkNoFollowthe prune path uses. A failure is a reportederroraction, never a throw, and never aborts the run. A corrupt manifest suppresses it like every other destructive action.This is the one change here that touches
safe-fs.ts(a file introduced in #30):tempNamePatternis exported next to the generator so the two spellings of the naming rule cannot drift. Deriving it was worth one small addition to an earlier file rather than hardcoding a copy inskills-fs.ts.Windows reserved device names (review finding DV-2)
The key grammar
^[a-z0-9][a-z0-9-]*$admitscon,prn,aux,nul,com1–com9andlpt1–lpt9— 22 names Windows resolves as devices rather than as paths, so none can be a directory name there. They are now rejected inkeyRejectionReason, ordered afterisValidSkillKeyto match the existing discipline in that function.Deliberately in the filesystem layer, not the shared key grammar.
isValidSkillKeyandSKILL_KEY_PATTERNintypes.tsare untouched, and that is not an oversight:parseAiConfigfails closed on a badskillsentry, by design. A grammar-level rejection would therefore invalidate the entire AI Config — model, provider, instructions, tools — for a Linux or macOS customer, over a constraint that only exists on Windows.skillRefswould silently shrink, and a dropped reference letswriteSkillsprune the skill's on-disk copy. That converts "this skill fails to write on Windows" into "this skill gets deleted on Linux."types.tsdocuments it in those words — "no mainstream filesystem allows a 256-byte path component, sowriteSkillsapplies a tighter bound of its own."keyRejectionReasonis already shared by the write and prune paths, so one edit covers both destructive paths.Unconditional, not platform-gated. A managed root written by a Linux container and read from a Windows host is an ordinary deployment, so the on-disk result must not depend on the writer's OS. And neither repo has a Windows CI runner — every job in both matrices is
ubuntu-latest— so aprocess.platform === 'win32'branch would be untestable in CI, which is the exact condition that produced this gap.The honest trade: a customer who legitimately names a skill
auxnow gets a reportederroraction where it would previously have worked off Windows.The set is exactly the 22 reserved names, as a
Set. Nocom0/lpt0— those are not reserved. No suffix stripping and no case folding, and the code says why: the grammar admits no.and no$, so thecon.txtandCONIN$/CONOUT$forms are unreachable, and keys are lowercase-only already.Residual, documented rather than implemented: the 255-byte bound is per component and does not bound the total path, so root + key +
/SKILL.mdcan still exceed Windows' 260-characterMAX_PATHwith a legal 200-character key. The SDK cannot validate that — the root is the customer's — so it is one README sentence, not a check.Not in scope
Neither change emits the Gap 1 integrity-failure log record that landed in #31: a key rejection and a clobber refusal are
ReconcileActionerrors, not integrity failures. Gap 2 (a typed outcome distinguishing integrity failure from absence) is a separate public-API decision and is not here.The abuse matrix that holds those in place
Path traversal, over seventeen hostile keys — parent traversal, absolute paths, backslashes, drive letters, an NTFS alternate data stream, an embedded null byte, uppercase, leading and trailing whitespace, a 257-byte key. Each asserts that no filesystem operation was attempted, not merely that none succeeded: the OS would reject several of these on its own, so a failed write is not evidence of a defense.
Symlink attacks on the root, on a skill directory, and on the target file, plus the swap-race pair. Those two are skipped off
SUPPORTS_DIR_FDand written out in full so they become live if Node ever grows the*at()family. The capability probe is itself tested, so a probe that silently reported "unsupported" could not also silently skip the cases that would have caught it.Clobber protection: a file at a managed path with no manifest entry is never overwritten — unless its bytes already are the resolved content, in which case it is adopted. Adoption, refusal on a one-byte difference, a mixed run that adopts one skill and refuses another, and the follow-on reconcile being an ordinary no-op are all covered.
Crash-mid-reconcile recovery, which the review asks for by name in the abuse matrix: a skill file on disk with no manifest entry and byte-identical content is adopted, reported
skipped_current, recorded in the manifest, and the next reconcile is a plain no-op.Targets that must not be read as ordinary files: a FIFO — with an explicit test timeout, so a regression fails rather than stalls CI — a directory standing where
SKILL.mdbelongs, and an unreadable file, which must refuse rather than overwrite and whose reason must be distinguishable from the collision refusal.All 22 reserved device names, through both
writeSkillsand the prune path, plus the assertion that makes the layer choice load-bearing:isValidSkillKey('con')is stilltrueandparseAiConfigstill accepts a config referencing it.com0andlpt0are not reserved and still write.Corrupt manifest, over eight variants. Six are unparseable, which makes "performed no destructive action" arithmetic rather than a defense — with no entries to act on there's nothing to prune and clobber protection covers the rest. The two
*_live_entriesvariants are the ones that actually test the rule: corrupt only inmanifestVersion, with a valid entries map listing the managed path under a matching key. The implementation has everything it needs to overwrite and to prune, and must refuse anyway.Also here
The last of the package-root exports and the assertions pinning their exact values — a caller needs
MANIFEST_FILENAMEto gitignore the manifest andMAX_SKILL_CONTENT_BYTESto pre-check content.One line from the accessor layer
resolveReferencebuildsResolutionvalues of its own for the two conditions only this path treats as data — an exhausted deadline and an absent store — so it now states thereasonfield that #31 made required. Both arestore_unavailable: neither is the store answering "no", and reporting either as an absence is what would let a prune delete working files over a non-answer. No behaviour change.Verification
Client package 452 → 555 tests, +2 skipped (the swap-race pair).
typecheck,biome, andsherifclean. The FIFO case skips on Windows and the unreadable-file case skips as root; both run on the CI matrix.Known coverage gap at this commit:
writeSkills's defenses are proven here, but its ordinary behaviour — reconcile semantics, atomicity, resilience — is covered in the next PR. The suite is green either way; this is a deliberate ordering so the non-relaxable checks ship alongside the tests that prove them.🤖 Generated with Claude Code
Note
Overview
Introduces
writeSkillsto reconcile LaunchDarkly skill content under a managed root (<key>/SKILL.md) using a.launchdarkly-skills.jsonmanifest, returning aReconcileReportinstead of failing silently. The package root now exportswriteSkills, manifest/filename constants, andWriteSkillsOptions(prune, timeout in seconds,onUnavailable).The reconcile is manifest-driven and fail-closed: shared
keyRejectionReason/unsafePathReasonguard writes and prunes; only manifest-owned paths may be overwritten or removed; corrupt or unreadable manifests block destructive work while still allowing new writes where safe. Post-crash adoption records orphaned files asskipped_currentwhen on-disk bytes match the resolved hash (without weakening clobber protection).readRegularFileavoids blocking reads on FIFOs/devices; Windows reserved device names are rejected at the filesystem layer;safe-fsgainstempNamePatternfor bounded orphan temp sweeps.Adds a large
skills-fs.test.tsabuse matrix (traversal, symlinks, clobber/adoption, corrupt manifest, etc.) plus package export assertions inskills.test.ts.Reviewed by Cursor Bugbot for commit afbd8f0. Bugbot is set up for automated code reviews on this repo. Configure here.