Conversation
XieX
force-pushed
the
xie/skills-07-docs
branch
from
September 14, 2026 20:13
c7fd991 to
defbc1a
Compare
XieX
force-pushed
the
xie/skills-08-review-closeout
branch
from
September 14, 2026 20:13
90b45a5 to
888a616
Compare
XieX
added a commit
to launchdarkly/python-ai-sdk
that referenced
this pull request
Sep 15, 2026
… remainder (#66) Stacked on #58. Closes the four items left open in [our response](https://launchdarkly.atlassian.net/wiki/spaces/PD/pages/5293965360) to the [Agent Skills security design review](https://launchdarkly.atlassian.net/wiki/spaces/PD/pages/5264506969). Three documentation items and one test item. **No behavior changes** — the code halves of rows 9 and 2 were already done and are deliberately untouched; the only change to `safe_fs.py` is its module docstring. ## 1. Privilege separation (row 9 docs half + counter-proposal for row 26) The proposed mitigation for **AZ-1**, the review's most serious finding. The README now documents reconcile-identity ≠ agent-identity as the recommended deployment, which is the whole reason the `0644`/`0755` modes deny anything: the agent reads its instructions and cannot rewrite them, or the manifest. Write access to the manifest is the worse half — it is what tells the *next* reconcile which paths the SDK may delete — which is why `_prune` re-validates every entry from scratch rather than trusting it. The operator gets an explicit check to run, because the SDK cannot run it. **`ReconcileReport` deliberately grows no writability field.** Row 26 asked for one; we declined. The SDK knows only its own identity, which trivially has write access, having just written there. Any check it could make would answer a different question than the one asked and manufacture false confidence exactly where the review wants caution. `agents.md` records that reasoning so the field is not added later by someone reading its absence as an oversight. ## 2. Three hostile-manifest prune tests (row 12 remainder) A well-formed manifest listing `/etc/passwd`, `../../../etc/passwd`, and a path under a parent that has since become a symlink. `_prune` already refuses all three; these turn asserted into verified. Two things make them worth more than their line count: - They are deliberately **well-formed**. The corrupt-manifest suite above them proves nothing here — a corrupt manifest suppresses every destructive action wholesale, whereas these manifests give the implementation everything it needs to prune, and it must refuse anyway because the recorded *path* is not one this SDK could own. - **"Deleted nothing" runs through an unlink spy**, not through checking that `/etc/passwd` still exists. The test process cannot delete that file anyway, so the obvious assertion would pass against an implementation with no path check at all — permissions would be doing the work. The spy proves the removal is never *attempted*. ## 3. One sentence on `"*"` (row 16 remainder) It materializes the whole project library, so every skill's `description` enters the agent's context — including skills no AI Config references and skills belonging to other teams. ## 4. Windows platform bound (row 2 residual) — deferred, explicitly **Decision: defer, with the bound written down.** Reparse-point checks (`GetFileAttributesW` / `FILE_FLAG_OPEN_REPARSE_POINT`) are not implemented, and that is now recorded in `safe_fs.py`, `agents.md` and the README rather than left implied. Rationale: - Windows is not a supported or tested platform for this release, and **neither repository has a Windows CI runner** — every matrix job is `ubuntu-latest` — so the checks would ship unverified. Both codebases already cite that absence as a reason not to add `os.name`/`process.platform` branches. - **The TypeScript SDK could not match them in any case.** Node exposes no `*at()` family on *any* platform, so its racy `lstat` floor is universal rather than Windows-only. Hardening Python alone would break the cross-language parity the two SDKs are held to. Two consequences are recorded rather than left to be rediscovered: on Windows write permission on the managed root is the *only* boundary, which is what makes privilege separation the mitigation and not merely advice; and this **retroactively lowers the priority of the row 25** reserved-device-name work, noted where that code lives. That code stays — it keeps a root written on Linux usable when read from Windows — but it is not evidence that Windows is hardened. ## Parity The Node half is launchdarkly/js-ai-sdk#48, case for case. No customer-visible surface moved: the `ld.skills.integrity_failure` event name, the eight `reason_code` tokens, the five `SkillOutcomeReason` tokens, and the log-record serialization are untouched. ## Verification - `uv run pytest` — 1484 passed, 11 skipped - `uv run ruff check .` / `ruff format --check .` — clean - `uv run mypy packages/*/src` (the CI gate) — no issues in 43 source files 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Overview** > Closes the remaining **Agent Skills security review** items with **documentation and tests only** — no runtime behavior changes beyond an expanded `safe_fs.py` module docstring. > > The **README** and **`agents.md`** now spell out deployment and platform limits: run **`write_skills` under a different identity than the agent** (with a shell check for agent writability), **decline a `ReconcileReport` root-writability field**, warn that **`"*"`** materializes the full skill library into context, and document the **POSIX-only** descriptor-pinned guarantee versus Windows’s racy `lstat` floor (reparse-point hardening explicitly deferred). > > **`test_skills_fs.py`** adds **`TestHostileManifestPrune`**: well-formed manifests that would prune `/etc/passwd`, traversal paths, or paths under a symlink-swapped parent must error without **`os.unlink`** being attempted (unlink spy), matching existing `_prune` defenses. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 6a0e6aa. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
XieX
force-pushed
the
xie/skills-07-docs
branch
from
September 16, 2026 18:26
defbc1a to
50721ca
Compare
… remainder The Node half of the four items left open in the response to the Agent Skills security design review. Three are documentation, one is tests; no behavior changes, and `safe-fs.ts`'s implementation is deliberately untouched. **Privilege separation** (row 9 docs half, and the agreed counter-proposal for row 26). The recommended deployment runs the reconcile as a different identity than the agent, which is the whole reason the `0644`/`0755` modes deny anything: the agent reads its instructions and cannot rewrite them, or the manifest. That is the mitigation for AZ-1, a prompt-injected agent editing its own skills. Write access to the manifest is the worse half — it is what tells the *next* reconcile which paths the SDK may delete — which is why the prune path re-validates every entry from scratch rather than trusting it. It matters more here than in Python, because this runtime's residual race *requires* write permission on the managed root, and privilege separation is what denies it. The README's security-posture note now points at the new section instead of ending on a bare "keep the root writable only by the SDK". The README also hands the operator the check to run, because the SDK cannot run it: it knows only its own identity, which trivially has write access, having just written there. So `ReconcileReport` grows no writability field — the review asked for one and we declined, since any check the SDK could make would answer a different question than the one asked and manufacture false confidence exactly where caution is wanted. `agents.md` records that reasoning so the field is not added later by someone reading its absence as an oversight. **Three hostile-manifest prune tests** (row 12 remainder): a well-formed manifest listing `/etc/passwd`, `../../../etc/passwd`, and a path under a parent that has since become a symlink. The prune path already refuses all three, so these turn asserted into verified, and they mirror the Python suite case for case. Two things make them worth more than their line count. They are deliberately *well-formed* — the corrupt-manifest block above them proves nothing here, because a corrupt manifest suppresses every destructive action wholesale, whereas these manifests give the implementation everything it needs to prune. And "deleted nothing" is asserted through an `fsOps.unlink` spy rather than by checking that `/etc/passwd` still exists: the test process cannot delete that file anyway, so the obvious assertion would pass against an implementation with no path check at all. **One sentence on** `'*'` (row 16 remainder). It materializes the whole project library, so every skill's `description` enters the agent's context — including skills no AI Config references and skills belonging to other teams. **The platform bound is now explicit** (row 2 residual), in `safe-fs.ts`, `agents.md` and the README — and it is a sharper bound here than in Python. Node exposes no `*at()` family on *any* platform, so the racy per-component `lstat` floor is not the Windows fallback it is in Python; it is the only implementation, Linux included. The README previously said this of the rename alone; it is true of every destructive step. Windows reparse-point checks (`GetFileAttributesW` / `FILE_FLAG_OPEN_REPARSE_POINT`) are not implemented, by decision: Windows is not a supported or tested platform for this release, neither repository has a Windows CI runner, and Node offers no primitive that would make such checks meaningful here anyway. This retroactively lowers the priority of the row 25 reserved-device-name work, noted where that code lives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two untrusted-store answers were read as data rather than as failures.
`allRawObjects` collapsed a non-object listing to `{}` with no error, so
a store that served nothing usable was indistinguishable from one holding
no skills. `resolveFromStore` read identity off the object without
checking it against the key that was asked for, so an answer served under
a different key came back under the caller's key while carrying its own.
Both are now withheld and reported, alongside the version check that
already guarded the same way. `allRawObjects` now returns the listing
together with the reason it could not answer, with a throwing store
caught and worded in the same place, so `allSkills` and the `'*'`
reconcile path no longer each re-derive the log line and the message.
Ports launchdarkly/python-ai-sdk 5817d89.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Covers the reconcile side of the withheld-answer fix: a listing that is not an object leaves every managed file alone rather than reading as a full revocation, and an answer served under a different key writes nothing, is reported against the key that was asked for, and does not reach that other key's file. Each one previously deleted a file and reported a clean run. Ports launchdarkly/python-ai-sdk 4c6d965. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…es not close Three expected-failure tests (skills-fs-root-swap.test.ts) for SEC-8985 row 2. resolveRoot validates the root once and returns a path; nothing holds it open. Each write and prune re-opens <root>/<key> by path with O_NOFOLLOW, which guards only the final component, so a root swapped for a symlink after validation redirects the open into the attacker's directory. assertUnswapped does not notice, because it lstat's the same swapped path the handle came from. Precondition is write permission on the root's parent, which the README privilege-separation checklist does not mention, so the documented mitigation for Node's residual exposure does not cover this. The tests state the contract (nothing lands outside the root, no outside file is overwritten, no outside file is removed) under a knownGap() wrapper: pytest's strict xfail narrowed to assertion errors, which it.fails is not, so a harness breakage is a real failure and the wrapper fails the run the day the body passes. Separate file because the swap must land before the per-skill directory is opened, which means intercepting mkdir/open from node:fs/promises with a file-wide vi.mock. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 2b9025e)
The expected-failure marker made the suite green while the contract was violated. A red run is the demonstration: the tests assert the contract, the code does not meet it, and they go green when the root is pinned for the reconcile, with nothing to remove. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> (cherry picked from commit 31d47db)
SEC-8985 row 2. `resolveRoot` validated the managed root and returned a path; nothing held it open. Every write and prune then opened `<root>/<key>` by path with `O_NOFOLLOW`, which guards only the final component, so the root was re-resolved on each one and a root swapped for a symlink after validation redirected the open into the attacker's directory. `assertUnswapped` agreed with itself, because it lstat'd the same swapped path the handle came from. Only the manifest write pinned the root, and by then the skill files were already outside it. The precondition is write access to the root's *parent* — `.claude` for a root of `.claude/skills`, which the agent identity typically owns. The root is now opened `O_RDONLY|O_DIRECTORY|O_NOFOLLOW` once, fstat'd, and held for the whole reconcile, and every child is addressed as `/proc/self/fd/<fd>/<name>`. The kernel resolves that prefix to the pinned inode rather than to the name, which gives it the property an `*at()` call would have; the per-skill directory is pinned in turn for the files inside it. Gated on a new `SUPPORTS_PROC_FD` probe (Linux plus a usable `/proc/self/fd`, verified at load by resolving a live descriptor back to its own inode), so macOS and Windows keep the per-component lstat floor unchanged — `assertUnswapped` is skipped only for a directory the caller addressed through a descriptor, which is why the floor's own tests still exercise it on Linux. Containment checks in `unsafePathReason` stay as defense in depth; they are no longer the boundary here. A swap landing before the pin fails the open and is reported as a run-level error rather than thrown, since the root was usable when the caller named it. `atomicWriteIn` is gone: the root is always pinned by the caller now, so a primitive whose job was to re-open a directory by path is exactly the footgun this bug was made of. Descriptor addresses are stripped out of `errno` messages, which leaves the offending file named relative to whichever directory was pinned. Tests: the two `<root>/<key>` races in skills-fs.test.ts now run wherever either capability probe is true, so they are live on Linux. The root-swap file from Security's PR #49 keeps its assertions; its trigger matches the final component plus the shape of its parent rather than one exact path, so the same bodies fire the swap against both the old and new addressing — pinning the trigger to `path.join(root, key)` would have made them pass vacuously the moment the fix landed. Verified in a Linux container: all four fail against the pre-fix code with real assertion failures (the write escapes, the outside victim is clobbered, the outside victim is unlinked) and pass against this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
XieX
force-pushed
the
xie/skills-08-review-closeout
branch
from
September 16, 2026 18:48
0a15b10 to
19acd23
Compare
andrewklatzke
approved these changes
Sep 16, 2026
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.
Stacked on #35. The Node half of the four items left open in our response to the Agent Skills security design review.
Three documentation items and one test item. No behavior changes — the only change to
safe-fs.tsis its module docstring.1. Privilege separation (row 9 docs half + counter-proposal for row 26)
The proposed mitigation for AZ-1, the review's most serious finding.
The README now documents reconcile-identity ≠ agent-identity as the recommended deployment, which is the whole reason the
0644/0755modes deny anything: the agent reads its instructions and cannot rewrite them, or the manifest. Write access to the manifest is the worse half — it is what tells the next reconcile which paths the SDK may delete — which is why the prune path re-validates every entry from scratch rather than trusting it.It matters more here than in Python. This runtime's residual race requires write permission on the managed root, and privilege separation is exactly what denies it. The security-posture note now points at the new section instead of ending on a bare "keep the root writable only by the process running the SDK".
ReconcileReportdeliberately grows no writability field. Row 26 asked for one; we declined. The SDK knows only its own identity, which trivially has write access, having just written there. Any check it could make would answer a different question than the one asked and manufacture false confidence exactly where the review wants caution.agents.mdrecords that reasoning so the field is not added later by someone reading its absence as an oversight.2. Three hostile-manifest prune tests (row 12 remainder)
A well-formed manifest listing
/etc/passwd,../../../etc/passwd, and a path under a parent that has since become a symlink. The prune path already refuses all three; these turn asserted into verified, and they mirror the Python suite case for case.Two things make them worth more than their line count:
fsOps.unlinkspy, not through checking that/etc/passwdstill exists. The test process cannot delete that file anyway, so the obvious assertion would pass against an implementation with no path check at all — permissions would be doing the work. The spy proves the removal is never attempted.3. One sentence on
'*'(row 16 remainder)It materializes the whole project library, so every skill's
descriptionenters the agent's context — including skills no AI Config references and skills belonging to other teams.4. Platform bound (row 2 residual) — deferred, explicitly, and sharper here
Decision: defer, with the bound written down. Reparse-point checks (
GetFileAttributesW/FILE_FLAG_OPEN_REPARSE_POINT) are not implemented, and that is now recorded insafe-fs.ts,agents.mdand the README.The bound is stronger in this SDK than in Python, and the README understated it. It previously said Node's missing
renameat/unlinkataffects "the final rename". In fact Node exposes no*at()family at all, so no destructive step can be descriptor-relative, and the racy per-componentlstatfloor is the only implementation — Linux included, not a Windows-only fallback as it is in Python. That is now stated plainly.Rationale for deferring the Windows work:
ubuntu-latest.agents.mdalready cites that absence as a reason not to addprocess.platformbranches.This retroactively lowers the priority of the row 25 reserved-device-name work, noted where that code lives. That code stays — it keeps a root written on Linux usable when read from Windows — but it is not evidence that Windows is hardened.
Parity
The Python half is launchdarkly/python-ai-sdk#66, case for case. No customer-visible surface moved: the
ld.skills.integrity_failureevent name, the eightreason_codetokens, the fiveSkillOutcomeReasontokens, and the log-record serialization are untouched.Verification
yarn test— all workspaces pass (client: 646 passed, 2 skipped; the 2 skips are the pre-existing TOCTOU tests gated offSUPPORTS_DIR_FD)yarn typecheck— cleanyarn code:check(Biome) — clean🤖 Generated with Claude Code
Note
Overview
This PR closes Agent Skills security-review follow-ups with real runtime hardening, not docs-only work.
writeSkillsnow pins the managed root for the whole reconcile and routes child paths through/proc/self/fd/<fd>/…on Linux (SUPPORTS_PROC_FD,directoryAddress), closing the case where swapping the root (or a parent) for a symlink could redirect writes, updates, or prunes outside the tree.atomicWriteInis removed; manifest and skill I/O usePinnedDirectory(pathfor reports,addressfor syscalls), and descriptor paths are stripped from operator-facing errors.Untrusted
SkillStoreanswers are tightened:allRawObjectsreturns{ objects, error }instead of treating throws or non-object listings as “empty,” so'*'reconcile will not prune everything on a broken list;resolveFromStorerejects objects whosekeydoes not match the request (accessors and materialization). Matching tests cover root-swap races (newskills-fs-root-swap.test.ts), store aliasing, listing failures, and well-formed manifest prune refusals (absolute/traversal paths and symlinked parents), plus TOCTOU tests now run whenSUPPORTS_PROC_FDis true.Documentation expands
'*'scope, Linux vs macOS/Windows TOCTOU bounds, privilege separation (reconcile user ≠ agent, ancestor writability checklist), andagents.mdguidance not to add Windows reparse checks or a managed-root writability field onReconcileReport.Reviewed by Cursor Bugbot for commit 19acd23. Bugbot is set up for automated code reviews on this repo. Configure here.