Skip to content

feat(client): Agent Skills — self-healing reconciles, and keys no filesystem can hold - #57

Merged
XieX merged 17 commits into
split/skills-fs-hardeningfrom
split/skills-self-healing
Sep 15, 2026
Merged

XieX merged 17 commits into
split/skills-fs-hardeningfrom
split/skills-self-healing

Conversation

@XieX

@XieX XieX commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Sixth PR in the Agent Skills stack, and the first that is not part of the original
five-PR split. Implements Gap 3 (AV-3) and Gap 4 (DV-2) from the Agent
Skills SDK security design review. Both live in skills_fs.py, so they ship
together.

Stack: #50 references → #51 retrieval → #52 safe-fs → #53 materialization →
#54 fs-hardening → #57 (this).

Why this is a new PR rather than an edit to #53

The code Gap 3 changes lives in skills_fs.py, which #53
(split/skills-materialization) owns. Editing #53 would be the wrong move:

Being a new branch on top, nothing below needs rebasing and no approval is
disturbed.

Gap 3 — self-healing partial reconciles (review priority: Medium)

_write_all reconciles every skill and only then does _rewrite_manifest write
the manifest, 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 _write_one treats as an unmanaged-file
collision. The skill was then wedged permanently: every later reconcile took the
same refusal branch. Boot-time execution under a ten-second budget makes the
crash window realistic.

_write_one now reads and hashes first, and decides from the bytes:

if exists:
    read on-disk bytes via _read_regular_file
    if sha256(on_disk) == content_hash  -> adopt: record the entry, report skipped_current
    if not managed                      -> refuse, with the existing message
    else                                -> action = "updated"
else:                                      action = "written"

This cannot weaken the clobber guarantee, because only byte-identical
LaunchDarkly content is ever adopted. Differing unmanaged bytes are still
refused, untouched. The existing clobber tests pass unchanged — including
test_unmanaged_file_is_never_overwritten, which writes "user authored\n".

Three details carry the safety:

  • A failed read is a refusal, never an overwrite, with a message
    distinguishable from the byte-mismatch refusal. It is the comparison that would
    otherwise authorize the write, and a file whose bytes could not be read has not
    been shown to be ours.
  • The read stays on _read_regular_file, not Path.read_bytes. Adoption
    widens it to genuinely foreign files, so its refusal of FIFOs and other
    non-regular files is now load-bearing rather than defensive.
  • skipped_current is reused rather than adding an adopted kind, so
    ReconcileActionKind in types.py — public, and owned by approved feat(client): Agent Skills #50/feat(client): Agent Skills — materialize onto disk under a manifest (4/5) #53
    does not change. Its documented meaning ("the bytes on disk already are the
    resolved content") already fits, and the reuse is documented in a comment.

One decision the brief did not settle: a bound on the read

Adoption reads files the manifest does not list, so _read_regular_file gained a
max_bytes parameter and reads at most len(content) + 1 bytes. Without it,
adoption would be a memory amplifier: a multi-gigabyte file at a managed path
would be slurped whole and then refused, where before the change it was refused
without being read at all. The + 1 is load-bearing — a bound of exactly
len(content) would adopt (and later prune) any customer file that merely
begins with the resolved content. There is a test for that off-by-one, and it
fails if the + 1 is removed.

The caveat, stated plainly

Adoption 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 matters is preserved: differing unmanaged
bytes are never overwritten.
There is a test pinning the caveat too, so it
cannot change silently.

The review also floats a write-intent journal as an alternative. Assessed as
over-engineered; not built. No case was found where the adoption rule fails.

Gap 3, secondary — orphaned temp files (landed, not deferred)

atomic_write names its temp file .SKILL.md.<16 hex>.tmp and unlinks it on
exception — but not after a SIGKILL. _prune walks manifest entries, which an
orphan never has, so nothing would ever notice one. The second-order effect is
worse than the wasted bytes: _prune_one's rmdir only succeeds on an empty
directory, so a single orphaned temp pins a skill's directory forever.

A sweep now runs on both the write and the prune path (before the rmdir, so it
actually unblocks it). It is the one place this SDK removes a file the manifest
does not list, so it is bounded on every axis:

  • inside <root>/<key>/ only, for a key that passes _key_rejection_reason;
  • only names safe_fs itself recognizes — a new is_temp_name / temp_name_prefix
    pair beside the naming code, matched with fullmatch so both ends are anchored.
    The pattern is derived from the producers rather than copied: atomic_write
    and _mkstemp_at now build their names from the same constants the recognizer
    reads, and the recognizer covers both producers (secrets.token_hex(8) on the
    descriptor path, tempfile.mkstemp's own sequence on the Windows fallback);
  • only regular files, with the type read off the descriptor, never a followed path;
  • unlinked through the existing descriptor-pinned unlink_file — no new
    path-based unlink;
  • never raises, never aborts a run; a failure is a logged warning.

Gap 4 — Windows reserved device names (review priority: Low)

The key grammar ^[a-z0-9][a-z0-9-]*$ admits con, nul, aux, prn,
com1com9, lpt1lpt9 — 22 names, none of which can be a directory name on
Windows. Rejected in _key_rejection_reason, after is_valid_skill_key, matching
the existing ordering discipline in that function.

It belongs in the filesystem layer, not the shared key grammar

types_validation.skill_key_rejection_reason / is_valid_skill_key are
deliberately untouched. Please do not "correct" this:

  • parse_ai_config fails closed on a bad skills entry, by design. A
    grammar-level rejection would therefore invalidate the entire AI Config
    model, provider, instructions, tools — for a Linux customer, over a constraint
    that only exists on Windows.
  • skill_refs would silently shrink, and its own docstring explains the
    consequence: a dropped reference lets write_skills prune the skill's
    on-disk copy. That converts "this skill fails to write on Windows" into "this
    skill gets deleted on Linux."
  • Precedent: the 255-byte component bound already lives in this layer for
    exactly this reason, documented in both codebases in those words.
  • _key_rejection_reason is already shared by the write and prune paths, so one
    edit covers both destructive paths.

A test asserts the layer choice directly: for each of the 22 names,
is_valid_skill_key is still True, parse_ai_config still accepts a config
referencing it, and skill_refs still projects it.

Unconditional, not platform-gated

No if os.name == "nt".

  • A managed root written by a Linux container can be read from a Windows host — an
    ordinary deployment — so the on-disk result must not depend on the writer's OS.
  • Neither repo has a Windows CI runner (verified: every job in ci.yml is
    ubuntu-latest), so a platform-gated branch would be untestable in CI — the
    exact condition that produced this gap.

The trade, stated honestly and belonging in the release notes: a customer who
legitimately names a skill aux now gets a reported error action on Linux where
it would previously have worked.

The set is exactly 22 names. No suffix stripping and no case folding are needed,
and a comment says why: the key grammar admits no . and no $, so the con.txt
and CONIN$ / CONOUT$ forms are unreachable, and keys are already lowercase-only.
com0 / lpt0 are not reserved and are not included — there is a test for
that, and for con1 / nul2 / conx.

Residual documented, not implemented

MAX_PATH overflow. The 255-byte bound is per component and does not bound the
total path — <customer root> + <key> + /SKILL.md can still exceed Windows'
260-character MAX_PATH with a legal key. The SDK cannot validate this because
the root is the customer's, so it is one README sentence rather than a check.

Scope and non-goals

  • Neither gap emits the Gap 1 integrity-failure log record. A key rejection and a
    clobber refusal are ReconcileAction errors, not integrity failures — no
    reason_code tokens, no record_integrity_failure call.
  • No check in _unsafe_path_reason, _key_rejection_reason, or safe_fs.py was
    relaxed. agents.md marks these non-relaxable, and the new material there says
    the same about the adoption rule and the sweep, so a later contributor cannot
    widen either.
  • No public API change: types.py is untouched.
  • Gap 2 (typed outcome for integrity failure vs absence) is deliberately not
    here — it is a separate public-API decision, planned next. Gap 1 landed in feat(client): Agent Skills — retrieval through an injectable store (2/5) #51
    and was not modified.

Docs

  • packages/client/README.md: the adoption rule under the existing write_skills
    conservatism paragraph, the reserved-name rejection with the honest trade, and
    the MAX_PATH residual.
  • packages/client/agents.md: the 22-name list, the adoption rule and its
    non-widenable details, the layer-choice reasoning, and the sweep's bounds — all
    in the non-relaxable-checks material.

Tests

packages/client/tests/test_skills_fs.py, +388 lines, 91 new cases:

  • Crash-mid-reconcile recovery (the review asks for this by name in the abuse
    suite): a byte-identical file with no manifest entry is adopted, reported
    skipped_current, recorded in the manifest, and the next reconcile is an
    ordinary no-op. Plus: adoption writes nothing (os.replace spy sees no call),
    emits skipped_current telemetry, and the adopted file is prunable afterwards.
  • Byte-differing unmanaged content still refused, untouched, no write attempted.
  • The len + 1 off-by-one: a longer file sharing the content prefix is not adopted.
  • An unmanaged FIFO refused, not read, and does not hang.
  • A read error on an unmanaged file refuses rather than overwriting, with the
    distinguishable message, and adds no manifest entry.
  • Orphan swept on write; orphan no longer blocks rmdir on prune; eight lookalike
    names left alone; a symlink wearing the temp name is not removed and its target
    survives.
  • All 22 reserved names through both write_skills and the prune path, plus
    the grammar-level assertion above and the non-reserved neighbours.

Every new test was mutation-checked — reverting each of the four behaviours
(reserved-name check, sweep, adoption, the + 1) makes the suite fail.

Checks

uv run ruff format --check ., uv run ruff check ., uv run mypy packages/*/src
(CI's command), uv run pytest — all clean: 1469 passed, 11 skipped.

Note: uv run mypy . (the Makefile's typecheck target) fails on a
pre-existing duplicate-conftest module-resolution error in
packages/ai/tests / packages/claude-agents/tests, unrelated to this change and
present on main. CI runs mypy packages/*/src, which is clean.

🤖 Generated with Claude Code


Note

Overview
Agent Skills grows a production delivery path and clearer failure semantics, while write_skills becomes more recoverable and cross-platform-safe on disk.

Delivery and live materialization: New FDv2SkillStore pulls skills over LaunchDarkly’s FDv2 poll/stream channel (server-side SDK key only), holds last-known-good through outages, and exposes StoreDiagnostics. watch_skills debounces store change listeners into automatic re-reconciles so revocations can prune disk without a restart. InMemorySkillStore (and the store contract) gain remove_listener for clean watcher teardown.

Retrieval: get_skill_result returns a frozen SkillOutcome with SkillOutcomeReason (ok, absent, integrity_failure, store_unavailable, wrong_version) on the same path as get_skill — so callers can fail closed on tampering while get_skill still collapses failures to None.

Filesystem reconcile (skills_fs / safe_fs): If a managed path exists with no manifest entry but bytes match the resolved hash, the file is adopted (skipped_current) instead of wedging after a crash between writes and manifest update; reads are bounded (len(content) + 1). 22 Windows reserved device names are rejected at materialize/prune time on all platforms. Orphan temp files from killed atomic_write runs are swept via shared is_temp_name / temp_name_prefix in safe_fs.

Docs: README and agents.md document FDv2 usage, * vs pinned refs, privilege-separated deployment, and POSIX vs Windows filesystem guarantees.

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

XieX and others added 2 commits August 30, 2026 04:26
…esystem can hold

Two gaps from the Agent Skills security design review, both in `skills_fs.py`:
AV-3 (partial reconciles are unrecoverable) and DV-2 (the key grammar admits
Windows device names).

**AV-3 — adopt a file whose bytes already are the resolved content.**
`_write_all` reconciles every skill and only then rewrites the manifest, once,
last. A process killed in that window leaves a skill file at a managed path with
no manifest entry — exactly the condition `_write_one` treats as an unmanaged-file
collision, so the skill was wedged permanently: every later reconcile took the
same refusal branch. Boot-time execution under a ten-second budget makes the
crash window realistic.

`_write_one` now reads and hashes first and decides from the bytes. Content
byte-identical to what LaunchDarkly resolved is adopted — manifest entry
recorded, reported `skipped_current` — and anything else falls through to the
same refusal as before. This cannot weaken the clobber guarantee: differing
unmanaged bytes are never overwritten, and the existing clobber tests pass
unchanged. Three details carry the safety:

- A read that fails is a refusal, never an overwrite, with a message
  distinguishable from the byte-mismatch refusal — it is the comparison that
  would otherwise authorize the write, and a file that could not be read has not
  been shown to be ours.
- The read stays on `_read_regular_file`. Adoption widens it to genuinely foreign
  files, so its refusal of FIFOs and other non-regular files is now load-bearing
  rather than defensive. It gains a `max_bytes` bound of `len(content) + 1` —
  enough to prove inequality for anything longer, and what keeps a foreign file
  of arbitrary size out of memory. A bound of exactly `len(content)` would adopt
  every file that merely begins with the resolved content.
- `skipped_current` is reused rather than adding an `adopted` action kind, so
  `ReconcileActionKind` — public, and owned by an approved PR — does not change.
  Its documented meaning already fits.

Adoption also makes the file prunable later. That is correct rather than a
weakening: only byte-identical LaunchDarkly content is ever adopted, so a later
prune removes content LaunchDarkly delivered anyway — what would have happened
had the crash not occurred.

The review also floats a write-intent journal. Assessed as over-engineered; not
built.

**AV-3, secondary — sweep orphaned temp files.** `atomic_write` unlinks its temp
file on any exception but not after a `SIGKILL`, and `_prune` walks manifest
entries, which an orphan never has, so nothing would ever notice one. The
second-order effect is worse than the disk: `_prune_one`'s `rmdir` only succeeds
on an empty directory, so a single orphan pins a skill's directory forever.

The sweep runs on both the write and the prune path, and is the one place this
SDK removes a file the manifest does not list, so it is bounded on every axis:
inside `<root>/<key>/` only, for a key that passes `_key_rejection_reason`; only
names `safe_fs` itself recognizes, via a new `is_temp_name` beside the naming
code rather than a copy of the format string that could drift from it; only
regular files, with the type read off the descriptor; unlinked through the pinned
descriptor. It never raises and never aborts a run.

**DV-2 — reject the 22 Windows reserved device names.** `con`, `prn`, `aux`,
`nul`, `com1`–`com9`, `lpt1`–`lpt9` are all valid skill keys and none can be a
directory name on Windows. Rejected in `_key_rejection_reason`, which the write
and prune paths already share, and *not* in the key grammar: `parse_ai_config`
fails closed, so a grammar-level rejection would invalidate an entire AI Config
for a Linux customer over a Windows-only constraint, and would silently shrink
`skill_refs` — which is what authorizes a prune, turning "fails to write on
Windows" into "gets deleted on Linux". The 255-byte component bound is in this
layer for the same reason.

Unconditional, not platform-gated: a root written from a Linux container is
routinely read from a Windows host, and neither repository has a Windows CI
runner, so a gated branch would be untestable — the condition that produced the
gap. No suffix stripping and no case folding: the grammar admits no `.` and no
`$`, so `con.txt` and `CONIN$` are unreachable, and keys are lowercase-only.
`com0` and `lpt0` are not reserved and are not included. The trade is real and
belongs in the release notes: a customer who legitimately names a skill `aux`
now gets a reported `error` action on Linux where it previously worked.

Neither gap emits the integrity-failure log record. A key rejection and a clobber
refusal are `ReconcileAction` errors, not integrity failures.

Tests cover crash-mid-reconcile recovery end to end (adopted, reported, recorded,
and the next reconcile an ordinary no-op), byte-differing content still refused
untouched, an unmanaged FIFO refused without hanging, a read failure refusing
rather than overwriting, the `len + 1` off-by-one, the sweep and the `rmdir` it
unblocks, lookalike temp names and a symlink wearing one left alone, all 22
reserved names through both destructive paths, and — the point of the layer
choice — each reserved name still valid to `is_valid_skill_key`,
`parse_ai_config`, and `skill_refs`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…failure

``get_skill`` returns ``None`` for four unrelated outcomes: no such skill, the
store raised, the requested version is not the one held, and content that failed
hash verification. A caller cannot fail closed on suspected tampering while
tolerating a merely-absent skill, so no automated customer-side response is
possible — finding LA-2 of the Agent Skills security design review.

The information already existed internally, as prose in ``Resolution.error``.
This gives it a token: ``Resolution`` grows a typed ``reason``, set explicitly at
every construction site and declared without a default so a sixth outcome added
later has to choose which public token it maps to. ``get_skill_result`` maps that
straight through to a frozen ``SkillOutcome`` (``skill``, ``reason``,
``detail``). Deriving the public reason by matching the error string is the
fragility LA-2 is about, so the mapping is readable in one table.

``get_skill`` is untouched — its ``None``-for-every-failure contract is
documented in its docstring and in the README, and a test now pins that all four
failures still collapse to ``None`` and still never raise.

Nothing new is emitted: Gap 1's integrity record already fired inside
verification before ``resolve_from_store`` returned, and a test asserts one
failed retrieval still produces exactly one record and one signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX and others added 8 commits September 4, 2026 10:19
… remainder

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 the
code halves of rows 9 and 2 are 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
``_prune`` re-validates every entry from scratch rather than trusting it.

The README documents the pattern and 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. ``_prune`` already refuses all three,
so 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, 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 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 Windows platform bound is now explicit** (row 2 residual), in
``safe_fs.py``, ``agents.md`` and the README. 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 so the checks would
ship unverified, and the TypeScript SDK could not match them in any case
because Node exposes no ``*at()`` family on *any* platform. Implementing them
in Python alone would break cross-language parity and trade a documented bound
for an unverified one. 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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
write_skills is a one-shot reconcile, so a revocation takes effect at the
next process restart. watch_skills runs that reconcile now and again
whenever the configured store reports a change, so a revoked skill's
files leave the disk within a debounce interval of the store learning
about it. It is wired to the SkillStore interface, not to any one
transport: it needs a store that implements add_listener and nothing
more, and refuses loudly when the store does not, since a watcher that
silently never fires looks exactly like one whose skills never changed.

Above the interface, remove_listener joins add_listener as the optional
second half of change notification, on the SkillStore contract and on
InMemorySkillStore. SkillWatcher.close needs it to detach; without it a
store held every watcher ever created for the rest of its life. The
watcher probes for it, so a store without it keeps working.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…network

The half of the delivery transport that has no I/O: identifying a skill
object on the wire by kind inline-resource plus category skill, translating
it into the raw object shape the SkillStore interface defines, holding it
by (key, objectVersion), and applying a payload's events as one commit at
payload-transferred. The store that puts a connection underneath this
follows separately, so the three decisions that matter most can be
reviewed on their own:

- objectVersion is the skill's version; version is the payload's. The
  translation happens in one place and TestVersionTranslation asserts it
  in both directions, because confusing them fails silently.
- Changes commit at payload-transferred, not per object. A half-applied
  full transfer would briefly empty the store, which with pruning on is
  the difference between a reconcile and deleting a customer's files.
- A hashless object is held, not dropped, so verification withholds it
  with a reason code rather than the transport reporting it absent.

Flag and segment objects share the connection and are skipped and
counted, not rejected. Nothing here is exported yet; the store exports
it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…oncile

watch_skills awaited write_skills and only then constructed SkillWatcher,
which is where the store listener attaches. The reconcile snapshots the
store as its first step and then spends the rest of its time on the
filesystem, so every write, fsync, prune and manifest rewrite in that
first pass ran with nothing listening. A change delivered in that window
was never seen, and since nothing re-reconciles on a timer, a revocation
that landed there waited for the next unrelated change — on a quiet root,
the next restart. Exactly the gap watch_skills exists to close.

The watcher now attaches its listener before the initial reconcile and
starts its worker after. notify only sets an event, so a change arriving
mid-reconcile is recorded and picked up by the worker's first pass, while
holding the thread back keeps write_skills's one-root-one-reconcile
contract: the worker cannot race the caller's own reconcile over the same
manifest. A reconcile that raises detaches the listener on the way out,
since the caller is handed an exception rather than a watcher to close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The protocol reader took payloads[0]'s intentCode and applied it to the
skill object set, which is what the delivery protocol requires — one
payload per credential, read the first intent, tolerate the rest — but it
left the assumption behind that rule undocumented and unguarded. If the
one-payload guarantee ever widens, an xfer-full for another payload would
start an empty pending set and the next payload-transferred would publish
it: every skill reported revoked, and with pruning on, a customer's files
deleted.

The first payload is still the payload that is read. What is new is that
the reader now knows which payload skills actually arrive on — learnt from
the intent's id, or from the (p:<id>:<version>) selector, since no object
or transfer event carries a payload id of its own — and declines to apply
a transfer of any other, holding last known good, warning once, and
counting it in diagnostics.payloads_ignored. An intent describing more
than one payload warns once on its own, because that is the one case the
comparison cannot catch: another payload's transfer arriving before any
skill has been seen has nothing to be compared against.

Behaviour under one-payload delivery is unchanged, and a full transfer of
the skill payload still empties it — every skill deleted is a real state
the guard must not mask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SDK-facing FDv2 channel now delivers skills the way streamer #4681 and
gonfalon #70638 spell them: object kinds are open strings, the agent-skill
payload is classified `generic`, and every generic object carries only `key`,
`kind`, `version` and `object`, exactly like a flag. A skill arrives under
kind `skill` with its own version folded into the key as `<key>:<version>`.
There is no `category` field and no `objectVersion` field; both came from an
earlier streamer draft that never shipped.

Identification is now the kind alone. The wire key is split in one place,
`_split_wire_key`, and both the put and the delete translation go through it.
A key that will not split cleanly is held rather than dropped — version-less,
or with the offending text as its version — so verification withholds it with
`invalid_version` under a key the caller recognises; only a key with nothing
before the delimiter is dropped, since there is no identity to hold it under.

`SDK_DATA_MODEL_VERSION` goes with it: the connection's `mv` parameter only
accepts flag model versions, and generic payloads ignore it. The transport
stops sending it in the following change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
FDv2SkillStore puts LaunchDarkly's SDK-facing FDv2 channel underneath the
protocol layer: GET /sdk/poll and GET /sdk/stream, authenticated with the
environment's server-side SDK key, streaming by default. It carries
basis across requests, sends If-None-Match and treats 304 as a current
answer, retries with capped jittered backoff, honours Retry-After only up
to max_backoff, gives up after a bounded run of consecutive failures
where a committed payload resets the count, and keeps serving last known
good through every failure. A mobile key or client-side environment ID
is refused in the constructor. Standard library only.

close interrupts the socket rather than only setting a flag, because the
delivery thread lives in a read no flag can reach; without that every
shutdown of a healthy stream waited out the full join timeout.

The no-store message now names FDv2SkillStore first, and watch_skills
points at it as the store with a delivery transport.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
_Requester.stream wrapped only the connect as recoverable, so a read
timeout, reset or truncated chunk in the body reached the delivery loop
as whatever the socket raised. The loop read that as a bug and gave up:
delivery stopped for the process lifetime, taking updates and
revocations with it, the first time a socket died. read_timeout exists
to bound a stream that has gone quiet so the loop can reconnect, and
tripping it did the opposite.

The body now carries the same promise the connect already did. Wrapping
the line source rather than the whole read keeps protocol reader errors
out of it: those are raised from the consumer's loop body, where they
still surface as the bugs they are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
XieX and others added 2 commits September 14, 2026 14:36
…n promptly

Three faults in the delivery loop, all of which left the store reporting
itself healthy while doing less than it claimed.

**An up-to-date stream tripped the failure cap.** The consecutive-failure
count reset only at a commit, and an environment whose skills are not
changing answers every reconnect with `intentCode: "none"` and transfers
nothing. A stream only ever ends by being dropped, so each recycle of a
perfectly healthy idle connection counted as a failure — announced with a
`goodbye` or not — and `max_consecutive_failures + 1` of them stopped
delivery for the process lifetime, revocations included. The reset on
commit covered only the case where content had changed, which is the case
that was easy to test and not the case that runs in production.

`_TransferOutcome` now reports `up_to_date`, and a complete answer that
transfers nothing breaks the row of failures exactly as a commit does. An
intent this module does not recognise is still not an answer.

**`close` could not interrupt a poll.** The interrupt reached the streaming
connection only, so polling parked in its request with nothing to reach and
`close` returned when its join timed out — on a 300s-class request, long
after the process meant to exit. `_Requester` now tracks the response of a
poll in flight and offers `interrupt`, which `close` calls alongside the
stream's own. A request still inside its connect has no response to reach;
that one is bounded by `read_timeout`, and `start` no longer leaves the
store inert when a join times out around it. An interrupt we asked for is
no longer recorded as a delivery failure.

**`close` left a waiter parked.** `wait_for_skills` waited on the first
payload alone, so a shutdown racing a waiter added the waiter's whole
timeout to it. Delivery ending is now its own event: a waiter is released
by a payload, a give-up or a close, and reports whether a payload actually
arrived rather than merely that it was let go. That also settles what
`_give_up` had been quietly asserting — it set the first-payload flag to
unblock waiters, which made `wait_for_skills` answer `True` for a store
holding nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stream only ever ends by being dropped, and LaunchDarkly — and any proxy
in between — recycles a long-lived one. Every reconnect therefore logged
"Skill delivery failed" at WARNING, for as long as the process ran. Until
the previous commit that noise was bounded, because an idle stream gave up
after eleven recycles and went quiet; now that delivery correctly survives
them, it would run forever and describe a healthy store as failing.

A connection that got a complete answer before it ended — a committed
payload, or an up-to-date intent — delivered everything it was asked for,
so its reconnect is now DEBUG and says so. A connection that ended without
answering is the case the warning exists for and still gets it: a connect
that never landed, or a transfer that died part-way through.

Filling a customer's logs with a fault they do not have is not merely
untidy; it teaches them that the level which means something can be
ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@andrewklatzke andrewklatzke left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably worth getting another review on this one from someone more colocated with the skills stuff, but code looks good to me

Stacked on the protocol-layer PR (`xie/skills-fdv2-protocol`). Third of
three PRs split out of #69, and the one that makes the feature real: the
network underneath the protocol reader, exported as `FDv2SkillStore`.

## Why this shape

Skill content arrives over `GET /sdk/poll` and `GET /sdk/stream`,
authenticated with the environment's server-side SDK key. These are the
SDK-facing endpoints the base SDK's FDv2 data source uses, and the
channel that payload signing will eventually cover. No private route is
involved, and no credential other than the environment's own SDK key
ships to a customer host. Standard library only, so the content path
adds no dependency to a package whose sole runtime dependency is
`opentelemetry-api`.

## What's here

**`FDv2SkillStore`.** Authenticates, streams (default) or polls, carries
`basis` across requests, sends `If-None-Match` and treats 304 as a
first-class current answer, and serves `get_object` / `all_objects` /
`add_listener` / `remove_listener` from what the protocol reader has
committed. Capped jittered backoff; `Retry-After` honoured but clamped
to `max_backoff` and rejected when non-finite; bounded
consecutive-failure retries, where a committed payload resets the count.
One network timeout, `read_timeout`, whose default follows the mode: 10s
for a whole poll, 300s between reads on a stream. A mobile key or
client-side environment ID raises from the constructor. Last known good
survives every failure; `diagnostics` and `failed` report the
degradation.

**`close` interrupts the socket.** The delivery thread parks in a read
no flag can reach, and closing a urllib response from another thread
does not unblock CPython's buffered reader, so `_interrupt_read` shuts
the socket down underneath it. Without that every shutdown of a
*healthy* stream blocked for the full join timeout.

**Above the interface**, two strings: `NO_STORE_MESSAGE` now names
`FDv2SkillStore` first, since it is the first thing a user sees on a
missing store and offering only the development store was wrong once a
production transport existed; and `watch_skills`' refusal message names
it as the store with a delivery transport.

## Bugs found and fixed while testing the loop

Five, all sharing one shape: the store stopped delivering while
continuing to report itself healthy.

- **The consecutive-failure counter never reset in stream mode.**
`_stream_once` always ends by raising, so a reset on return was
unreachable and `failures` grew for the whole process lifetime. Eleven
*fully successful* payload transfers were enough to trip
`max_consecutive_failures` and stop delivery for good, revocations
included. A commit now resets the count, in `_apply`.
- **A non-finite `Retry-After` killed the delivery thread.**
`float("inf")` parses, and `Event.wait(inf)` raises `OverflowError` from
inside the recoverable-error handler. Non-finite values are rejected and
every honoured delay is clamped to `max_backoff`.
- **`close()` during the initial connect waited out its full join
timeout.** `self._connection` was assigned after the connect returned,
so a `close()` in that window found nothing to interrupt. The stop flag
is re-checked immediately after the assignment.
- **`close()` blocked for the full join timeout on every healthy
stream.** See `_interrupt_read` above.
- **A stream interrupted by our own `close` was reported as a delivery
failure.**

Also: `connect_timeout` was accepted and never used, so a poll against a
black-holed host hung for 300s rather than 10. It is gone, with the
request timeout now chosen by mode, and `TestTimeouts` measures the
bound against a socket that accepts and never answers.

## Tests

> **Rebase note.** The previous push of this branch had silently
reverted the protocol PR's last commit (payload identity:
`payloads_ignored`, `_is_foreign_payload`, `TestPayloadIdentity`).
Rebasing onto the updated protocol branch restored it; the full suite
passes with it present.

`_FakeFDv2Endpoint` is an in-process `ThreadingHTTPServer` implementing
the wire contract, so request construction and header handling are
exercised over real sockets rather than mocked. Covers skill put/delete
over the wire, mixed payloads, 304, `basis` round-tripping,
reconnect/backoff in both modes, `Retry-After` including non-finite and
oversized values, bounded retries and the reset on commit, prompt
shutdown during connect and during a healthy stream, hashless envelopes
end to end through the accessors, server-side-only credentials,
timeouts, and `watch_skills` over the transport: a wire-level revocation
pruning a file without a restart.

Full suite 1629 passing, 11 skipped; `ruff`, `ruff format`, and `mypy`
clean.

## Open items, none in this PR's scope

- 🔴 **`contentHash` is not on the wire yet.** Against a real environment
today every skill resolves to nothing. This PR makes that loud (an error
per hashless object, a summary per wholly-hashless payload,
`diagnostics.hashless_objects`) rather than surviving it.
- 🔴 **Server-side skill delivery is not deployed.** The wire shape this
store reads — kind `skill`, key `<key>:<version>`, generic payload — is
what [streamer
#4681](launchdarkly/streamer#4681) and [gonfalon
#70638](launchdarkly/gonfalon#70638) emit; both
are still open. No account can receive skill objects until they ship and
the producer is enabled.
- 🟡 **FDv2 is opt-in per account.** A real environment returns 403
today; the store reports it as fatal and explains what to do.
- ~~🟡 **`mv` is a guess.**~~ Resolved: the request sends no `mv`. That
parameter selects the *flag* data model and the connection rejects any
value but the flag default, while the generic agent-skill payload is
served regardless of it. The `data_model_version` constructor argument
is gone with it.
- 🟡 **No payload signing** on this channel yet, so Beta is TLS-only.
- 🟡 **The connection also carries the environment's flags.** Skipped and
counted; a transport property, not fixable here.
- 🟡 **`ld-relay` does not speak the FDv2 endpoints**, so relay-only
deployments cannot receive skills in Beta.

**Nothing here has touched a real LaunchDarkly environment**, because it
cannot yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Adds **`FDv2SkillStore`**, a production `SkillStore` that pulls agent
skills over LaunchDarkly’s SDK FDv2 **`/sdk/poll`** and
**`/sdk/stream`** endpoints (stdlib HTTP, background delivery thread,
stream-by-default). It implements **`SkillStore`** (`get_object`,
listeners, etc.) on top of the existing protocol reader, plus
**`StoreDiagnostics`**, **`wait_for_skills`**, capped backoff with
**`Retry-After`**, and **`close`** that interrupts blocked socket reads
so shutdown is prompt.
> 
> **Public surface:** `FDv2SkillStore` and `StoreDiagnostics` are
exported from the package; README documents production setup with
`init_client` and `watch_skills`. Server-side SDK keys only;
mobile/client credentials are rejected. Outages keep last-known-good
content; accessors above the store are unchanged.
> 
> **Delivery-loop fixes** bundled here: reset consecutive-failure counts
on successful commits / up-to-date answers (so healthy stream recycling
does not stop delivery), safe handling of non-finite **`Retry-After`**,
and not treating intentional **`close`** interrupts as transport
failures. Removed unused **`connect_timeout`**; **`read_timeout`** is
the single knob with mode-specific defaults.
> 
> **Tests:** in-process fake FDv2 server exercises poll/stream,
basis/ETag/304, revocations, retries, timeouts, hashless payloads, and
**`watch_skills`** over the transport.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
88c225e. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…network (#82)

Stacked on the `watch_skills` PR (`xie/skills-watch`). Second of three
PRs split out of #69; the transport that puts a connection underneath
this follows.

The half of the delivery transport that has no I/O: identifying a skill
object on the wire, translating it into the raw object shape the
`SkillStore` interface defines, holding it by `(key, objectVersion)`,
and applying a payload's events as one consistent commit. Splitting it
out lets the three decisions that matter most be reviewed without a
socket in the way.

## Three things worth reviewing closely

**1. The skill's version is in the object's `key`; `version` is the
payload's.** Each version of a skill is its own object on the wire,
identified as `<key>:<version>` (`pdf-extraction:3`), and that is the
only place the skill's version appears. The event's `version` is the
payload's, and confusing the two fails *silently*: the object verifies,
the hash matches, and the caller gets content under a version number
that means nothing. The wire key is split in exactly one place
(`_split_wire_key`), both the put and the delete translation go through
it, and `TestVersionTranslation` asserts it in both directions. A key
that will not split cleanly is *held* rather than dropped —
version-less, or with the offending text as its version — so
verification withholds it with `invalid_version` under a key the caller
recognises; only a key with nothing before the delimiter is dropped.

**2. Changes commit at `payload-transferred`, not per object.** A
payload version is the unit of consistency. A half-applied full transfer
would publish a state the server never described and would briefly empty
the store, which, with pruning on, is the difference between a reconcile
and deleting a customer's skill files. An interrupted transfer leaves
last known good intact, and listeners fire once per commit.

**3. A hashless object is held, not dropped.** Dropping it at the
transport would report `absent`, indistinguishable from "no such skill",
and would let a prune delete the last known-good copy on disk. Holding
it means verification withholds it with `missing_content_hash`, which is
diagnosable: an ERROR per `(key, version)`, deduped per reader rather
than per process so two stores never quieten each other, a summary per
wholly-hashless payload, and a `StoreDiagnostics.hashless_objects`
counter. There is deliberately no fallback that synthesises a hash from
the delivered content.

## Also here

- **Skills are `kind == "skill"`; everything else is ignored, not
rejected.** Object kinds on the SDK-facing channel are open strings and
the agent-skill payload is classified `generic`, so a skill arrives
under the kind its producer registered — the bare category name — with
no `category` or `objectVersion` field ([streamer
#4681](launchdarkly/streamer#4681), [gonfalon
#70638](launchdarkly/gonfalon#70638)). An
environment's assignment carries its flag payload alongside its
agent-skill payload, so flag and segment objects arrive as a matter of
course. Erroring on them would turn a normal payload into a permanent
reconnect loop.
- **`_SkillObjectSet`** holds several versions of one key, with lookup
semantics identical to `InMemorySkillStore` down to the fall-through to
a version-less entry. Its opaque snapshot keys are spelt
`<key>:<version>`, the same as the wire, and a test pins that round
trip. `TestInterfaceParity` asserts the two resolve identically.
- **`_require_server_side_credential`** refuses a mobile key or
client-side environment ID. Its tests arrive with the store constructor
that calls it.

Nothing here is exported yet; the store exports it. The module imports
nothing from the feature but the version validator.

## Tests

`test_skills_fdv2.py` drives `_ProtocolReader` directly: identification,
version translation, full and change transfers, interruption,
revocation, tombstones, mixed payloads, unknown kinds and events, error
and goodbye, and the hashless dedupe across readers. The wire builders
it introduces are shared with the transport PR's fake endpoint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Adds **`skills_fdv2.py`**, a stdlib-only layer below `SkillStore` that
parses LaunchDarkly FDv2 events without sockets. It maps wire
`put-object`/`delete-object` into the raw store shape, keeps skills in
**`_SkillObjectSet`**, and applies updates atomically in
**`_ProtocolReader`** at **`payload-transferred`** (not per object).
> 
> The critical wire rule is **`key` = `skillKey:objectVersion`** while
the event’s **`version` is the payload revision** and is
dropped—confusing them would silently serve the wrong pinned version.
Non-`kind == "skill"` objects (flags, segments) are **ignored**, not
errors. **Foreign payload** full transfers are declined once skills’
payload id is known, so a flag `xfer-full` cannot wipe held skills (and
trigger prune). **Hashless** skills are retained for verification to
withhold with `missing_content_hash`, with **`StoreDiagnostics`** and
loud logging. **`_require_server_side_credential`** rejects
mobile/client credentials (for the upcoming networked store).
> 
> **`agents.md`** documents the transport contract; **`skills_core`**
clarifies `SKILL_OBJECT_KIND` vs wire kind. **`test_skills_fdv2.py`**
(~900 lines) exercises the reader, version translation, payload
identity, and parity with `InMemorySkillStore`. Nothing is exported or
wired from accessors yet—that’s the follow-on transport PR.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
efc4ca7. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
…ls (#81)

Stacked on #66 (`split/skills-review-closeout`). First of three PRs
split out of #69; the FDv2 protocol layer and the transport follow on
top of this one.

`write_skills` is a one-shot reconcile, so a revocation takes effect at
the next process restart. `watch_skills` runs that reconcile now and
again whenever the configured store reports a change, so a revoked
skill's `SKILL.md` leaves the disk within a debounce interval of the
store learning about it, rather than at the next restart.

## What's here

**`skills_watch.py`** — `watch_skills` / `SkillWatcher`. Wired to the
`SkillStore` interface, not to any one transport: it needs a store that
implements `add_listener` and nothing more. It refuses loudly when the
store does not, because a watcher that silently never fires looks
exactly like one whose skills never changed. The listener itself only
sets an event; the reconcile runs on a single worker thread, debounced,
so a burst of changes coalesces into one pass and a slow disk never
stalls the store's delivery thread. A reconcile that raises is logged
and the watcher continues. `on_unavailable="keep"` stays the default: an
outage must not read as "everything was revoked".

**`remove_listener(kind, fn)`** joins `add_listener` as the optional
second half of change notification, on the `SkillStore` contract and on
`InMemorySkillStore`. `SkillWatcher.close()` needs it to detach; without
it a store held every watcher ever created for the rest of its life. The
watcher probes for it and skips detaching when a store does not
implement it, so a customer's own store keeps working.

## Tests

`test_skills_watch.py` drives the watcher through `InMemorySkillStore`,
whose `put` notifies synchronously, and through small store doubles:
initial reconcile, coalescing, refusal of a store without
`add_listener`, detaching on close, and a store without
`remove_listener`. Three tests on `InMemorySkillStore.remove_listener`
join `test_skills.py`. The end-to-end case, a `delete-object` arriving
over a live connection and pruning a file, lands with the transport PR
where the fake endpoint lives.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Adds **`watch_skills`** and **`SkillWatcher`** so agent skill files
stay in sync with the configured skill store without waiting for a
process restart. The API runs an initial `write_skills` reconcile, then
listens for store delivery changes and re-reconciles on a **debounced
background thread** (listener only wakes the worker; disk I/O never
blocks the delivery thread). Revocations can prune `SKILL.md` within the
debounce window. **`on_unavailable="keep"`** remains the default so
transport outages are not treated as mass revocations.
> 
> Extends the optional **`SkillStore`** change-notification contract
with **`remove_listener(kind, fn)`**, implemented on
**`InMemorySkillStore`**, so **`SkillWatcher.close()`** can detach;
stores without it still work but keep listeners registered.
**`watch_skills`** raises if no store is configured or the store lacks
**`add_listener`**, and cleans up the listener if the initial reconcile
fails.
> 
> Public exports and README/agents docs are updated; new tests cover
watcher behavior (coalescing, mid-startup revocations, close/detach) and
**`remove_listener`** semantics.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
29ad3fe. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
… 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 -->
…failure (Gap 2 / LA-2) (#58)

## Gap 2 (finding LA-2): a distinguishable outcome for integrity failure
versus absence

**This PR adds public API and needs review as such.** Three new exported
names:
`get_skill_result`, `SkillOutcome`, `SkillOutcomeReason`.

### The defect

`get_skill` returns `None` for four distinct outcomes — no such skill,
the store raised, the
requested version is not the one held, and content that failed hash
verification. A customer
therefore cannot fail closed on suspected tampering while tolerating a
merely-absent skill,
so no automated customer-side response is possible. LA-1 (shipped in
#51) gave the
*operator* a structured log record; this gives the *application* the
same distinction.

The information already existed internally. `Resolution` in
`skills_core.py` distinguished
all four cases — but only as prose in its `error` string, which
`get_skill` discarded with
`.skill`.

### The API

```python
SkillOutcomeReason = Literal["absent", "integrity_failure", "ok", "store_unavailable", "wrong_version"]

@DataClass(frozen=True)
class SkillOutcome:
    skill: Skill | None
    reason: SkillOutcomeReason
    detail: str | None

async def get_skill_result(key: str, *, version: int | None = None) -> SkillOutcome: ...
```

Chosen over a typed exception, an opt-in strict mode, and an init-time
callback. `detail` is
the existing human-readable reason string — already safe to surface
(skill key and failure
mode only, never content, never a filesystem path), and a test pins that
it stays that way.
`get_skill_result` raises `RuntimeError` only when no store is
configured, with the same
message as `get_skill`.

### `get_skill` does not change

Its contract — `None` for every failure, never raises for one — is
documented in its
docstring and in the README, and every existing caller treats `None` as
"no skill". A test
drives all four failures through both accessors: the reason is
distinguishable *and* the
collapsed form still collapses.

### Not matched on the error string

`Resolution` grows a typed `reason`, set explicitly at every
construction site. Deriving the
public reason by pattern-matching `Resolution.error` is exactly the
fragility LA-2 is about,
so the mapping is a table a reviewer can read:

| `resolve_from_store` outcome | `reason` |
|---|---|
| the store raised (`unavailable=True`) | `store_unavailable` |
| `raw` is not a dict | `absent` |
| `verify_raw_skill` returned `None` | `integrity_failure` |
| `skill.version != wanted_version` | `wrong_version` |
| success | `ok` |

`reason` is declared **first and without a default**, so a contributor
adding a sixth
internal outcome has to decide which public token it maps to rather than
inheriting one. That
made the two `Resolution` sites in `skills_fs.py` explicit as well —
both are the
"could not retrieve" path, both already `unavailable=True`, both now say
`reason="store_unavailable"`.

`Resolution.unavailable` is unchanged. It stays load-bearing on the
prune path — only a
raising store suppresses pruning, because deleting managed files after a
failed lookup would
turn an outage into data loss — and `store_unavailable` stays distinct
from `absent` for the
same reason.

### Three things deliberately not built

- **No new telemetry or log record.** Gap 1's
`ld.skills.integrity_failure` record already
fired inside verification before `resolve_from_store` returned. A test
asserts one failed
retrieval still produces exactly one log record and exactly one signal.
- **Gap 1's 8-token `IntegrityReasonCode` is not threaded into
`SkillOutcome`.**
`verify_raw_skill` returns `None` and does not surface which token
fired; plumbing it up
would change that function's return type for a detail the operator
already gets from the
  log record. The five-token public reason is the actionable surface.
- **No batch equivalents.** `get_skills` and `all_skills` keep omitting
failed entries and
keep logging the run-level WARN count. A `get_skills_result` /
`all_skills_result` would
double the accessor surface for a case nobody has asked for — **possible
follow-up** if a
  customer needs per-key reasons from a batch.

### Tests

`packages/client/tests/test_skills.py`, one new class plus three
additions to existing
export/immutability tests:

- one case per reason token — `reason`, whether `skill` is populated,
non-empty `detail`
- `store_unavailable` is distinct from `absent`: a raising store and an
empty store
- every non-`ok` outcome carries a detail (swept, so a fifth failure
path with no message is
  caught by a test whose name says what it is about)
- `get_skill` still returns `None`, and still never raises, for all four
failures
- `get_skill_result` raises `RuntimeError` with no store, asserted equal
to `get_skill`'s message
- no second integrity record or signal from `get_skill_result`
- `detail` never carries the skill content
- `SkillOutcome` is frozen; `SkillOutcomeReason`'s tokens and the three
new exports are pinned

### Docs

- `packages/client/README.md` — `get_skill_result` in the Agent Skills
API table, a
fail-closed example under the Gap 1 observability material (exit on
`integrity_failure`,
tolerate `absent`), the reason table, and an explicit statement that
`get_skill` is
  unchanged and the two accessors differ only in what they report.
- `packages/client/agents.md` — the five-token vocabulary, the
`Resolution` → reason mapping,
and the instruction that a sixth internal outcome must choose a public
token rather than
  defaulting to `absent`.

### Lockstep with TypeScript

The type name, the accessor name, and the five reason tokens are fixed
across both SDKs; a
sibling PR implements the identical shape in `typescript/`. Tokens are
alphabetical,
matching how `IntegrityReasonCode` was written on both sides in Gap 1.

### Placement

Based on `split/skills-self-healing` (#57), which is based on #54. A new
PR at the top of the
stack so it disturbs none of the existing reviews: Gap 2 spans
`types.py`, `skills.py`,
`skills_core.py` and `__init__.py`, which #50#53 own, and #52/#53/#54
are approved while
#50/#51 have live reviewer conversation.

### Checks

`uv run pytest` (1481 passed, 11 skipped), `ruff check`, `ruff format
--check` all clean.
`uv run mypy .` fails repo-wide on a pre-existing duplicate-`conftest`
error before checking
anything; scoped to the files this PR touches, mypy reports only two
pre-existing
`unused-ignore` warnings that are present on the base commit too.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Overview**
> Adds **`get_skill_result`** and frozen **`SkillOutcome`** /
**`SkillOutcomeReason`** so applications can tell **integrity failure**
apart from a missing skill, store outage, or version mismatch—without
changing **`get_skill`** (`None` for all failures, same path, no extra
telemetry).
> 
> Internal **`Resolution`** now carries a required **`reason`** mapped
explicitly at each **`resolve_from_store`** site (including
**`skills_fs`** store-unavailable paths); **`get_skill_result`** exposes
**`skill`**, **`reason`**, and safe-to-log **`detail`**.
> 
> README and **`agents.md`** document fail-closed handling; tests cover
all five reasons, immutability, exports, no double integrity signals,
and unchanged **`get_skill`** behavior.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
eda4ae1. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
@XieX
XieX merged commit b62571e into split/skills-fs-hardening Sep 15, 2026
4 checks passed
@XieX
XieX deleted the split/skills-self-healing branch September 15, 2026 14:35

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 7b59680. Configure here.

logger.debug("Skill payload unchanged (HTTP 304)")
# A 304 counts as a first payload, so a boot that reconnects with a
# cached basis is not blocked on a transfer the server will not send.
self._publish_first_payload()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up-to-date intent never unblocks waiters

Medium Severity

wait_for_skills only releases on a committed transfer or HTTP 304, not on xfer-none. That intent is documented as the same complete answer as a 304 — the server has nothing to send — and _apply already treats up_to_date as success for retries. Boot still waits out the full timeout and then returns False on a healthy connection, so callers proceed as if delivery failed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7b59680. Configure here.

committed=True,
changes=changes,
basis=state if isinstance(state, str) and state else None,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ignored payload still advances reconnect basis

Medium Severity

A foreign payload-transferred correctly leaves last known good skills in place, but still returns committed=True with that payload's state. _apply then overwrites _basis. The next reconnect asks LaunchDarkly for changes from the ignored payload's selector, so later skill updates and revocations can be skipped.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7b59680. Configure here.

target=self._run, name="ld-ai-skills-fdv2", daemon=True
)
self._thread.start()
return self

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restart after give-up keeps failed state

Medium Severity

start() after delivery has given up does not clear _failed_reason or _failures. failed keeps reporting the old error after a new thread is running, and the first recoverable drop on that thread increments a counter that is already past the cap, so delivery gives up again immediately unless a payload commits first.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7b59680. Configure here.

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.

2 participants