Skip to content

docs(client): Agent Skills — close out the security review's SDK-side remainder - #48

Open
XieX wants to merge 7 commits into
xie/skills-07-docsfrom
xie/skills-08-review-closeout
Open

XieX wants to merge 7 commits into
xie/skills-07-docsfrom
xie/skills-08-review-closeout

Conversation

@XieX

@XieX XieX commented Sep 4, 2026

Copy link
Copy Markdown

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.ts 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 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".

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. 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:

  • They are deliberately well-formed. The corrupt-manifest block 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 fsOps.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. 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 in safe-fs.ts, agents.md and the README.

The bound is stronger in this SDK than in Python, and the README understated it. It previously said Node's missing renameat/unlinkat affects "the final rename". In fact Node exposes no *at() family at all, so no destructive step can be descriptor-relative, and the racy per-component lstat floor 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:

  • 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. agents.md already cites that absence as a reason not to add process.platform branches.
  • Node offers no primitive that would make such checks meaningful here anyway, so the checks would harden Python only and break cross-language parity.

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_failure event name, the eight reason_code tokens, the five SkillOutcomeReason tokens, 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 off SUPPORTS_DIR_FD)
  • yarn typecheck — clean
  • yarn 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. writeSkills now 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. atomicWriteIn is removed; manifest and skill I/O use PinnedDirectory (path for reports, address for syscalls), and descriptor paths are stripped from operator-facing errors.

Untrusted SkillStore answers are tightened: allRawObjects returns { objects, error } instead of treating throws or non-object listings as “empty,” so '*' reconcile will not prune everything on a broken list; resolveFromStore rejects objects whose key does not match the request (accessors and materialization). Matching tests cover root-swap races (new skills-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 when SUPPORTS_PROC_FD is true.

Documentation expands '*' scope, Linux vs macOS/Windows TOCTOU bounds, privilege separation (reconcile user ≠ agent, ancestor writability checklist), and agents.md guidance not to add Windows reparse checks or a managed-root writability field on ReconcileReport.

Reviewed by Cursor Bugbot for commit 19acd23. Bugbot is set up for automated code reviews on this repo. Configure here.

@XieX
XieX force-pushed the xie/skills-07-docs branch from c7fd991 to defbc1a Compare September 14, 2026 20:13
@XieX
XieX force-pushed the xie/skills-08-review-closeout branch from 90b45a5 to 888a616 Compare September 14, 2026 20:13
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
XieX force-pushed the xie/skills-07-docs branch from defbc1a to 50721ca Compare September 16, 2026 18:26
XieX and others added 7 commits September 16, 2026 14:37
… 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
XieX force-pushed the xie/skills-08-review-closeout branch from 0a15b10 to 19acd23 Compare September 16, 2026 18:48
@XieX
XieX requested a review from andrewklatzke September 16, 2026 19:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants