Skip to content

feat(sleep): resolve discovered skill names through bounded local roots - #185

Open
bogdanbaciu21 wants to merge 2 commits into
microsoft:mainfrom
bogdanbaciu21:skoc-004-resolve-skill-names
Open

feat(sleep): resolve discovered skill names through bounded local roots#185
bogdanbaciu21 wants to merge 2 commits into
microsoft:mainfrom
bogdanbaciu21:skoc-004-resolve-skill-names

Conversation

@bogdanbaciu21

@bogdanbaciu21 bogdanbaciu21 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Fourth review-sized slice of #120. Skill names observed in transcripts are
untrusted strings, so before anything can act per skill there needs to be a
narrow, read-only way to turn a name into a local SKILL.md.

Adds skillopt_sleep/skill_resolver.py:

  • normalize_skill_name(name) accepts a single safe path segment only -
    whitespace-trimmed, case and punctuation preserved - and rejects empty names,
    ./.., separators, absolute/drive/~ paths, and control characters;
  • skill_search_roots(cfg) returns the documented roots that exist, in fixed
    precedence: <claude_home>/skills, then
    <claude_home>/plugins/cache/*/*/skills;
  • resolve_skill(name, roots) returns a frozen SkillResolution with status
    found / missing / ambiguous / rejected, so a name defined in no root is
    a different signal from one defined in several (candidates listed, no winner
    guessed).

Containment is checked after symlink resolution: a skill directory or SKILL.md
that points outside its root is refused, while a symlinked root itself still
resolves. The resolver only stats and reports - it never reads skill bodies,
writes, creates, or adopts anything - and no existing module or config behavior
changes, so legacy target_skill_path handling is untouched.

Rebased onto current main (e7014cd). This branch adds two new files and
edits none, so it can be reviewed and merged on its own; #184 is the logical
predecessor but not a technical dependency.

Tests

Base is unmodified main at e7014cd, same machine, same interpreter
(CPython 3.12.13, macOS):

Run Result
pytest tests/test_sleep_skill_resolver.py (this branch) 17 passed
pytest tests (this branch) 620 passed, 6 skipped, 130 subtests, 2 failed
pytest tests (base main @ e7014cd) 603 passed, 6 skipped, 130 subtests, 2 failed
ruff check . identical finding set to base - 0 new

The 2 failures are tests/test_superpowers_scenarios.py::TestOverlayIntegration
(test_skill_copied_to_correct_path, test_source_checkout_unchanged). They are
the macOS /var/private/var symlink artifact and reproduce unchanged on
untouched main, so they are not regressions from this branch.

Covered by the new tests: normalization accept/reject table, single local skill,
missing vs ambiguous, directory without SKILL.md, repeated root, traversal,
symlinked skill dir and symlinked SKILL.md escaping the root, symlinked root,
unchanged skill file after resolution, no roots, config root precedence, and
legacy target_skill_path. All fixtures are hermetic temp dirs with synthetic
names.

Refs #120

Copilot AI review requested due to automatic review settings July 28, 2026 16:34

Copilot AI 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.

Pull request overview

Adds a new, security-focused resolver for mapping untrusted “skill name” strings observed in transcripts to a bounded local SKILL.md path under documented Claude skill roots, without reading or modifying any skill content. This supports the broader Issue #120 direction by providing a safe building block for later per-skill fan-out.

Changes:

  • Added skillopt_sleep/skill_resolver.py with skill-name normalization, documented root discovery, and a resolution API that distinguishes found/missing/ambiguous/rejected.
  • Added tests/test_sleep_skill_resolver.py covering normalization rules, root precedence, ambiguity vs missing, traversal rejection, and symlink escape containment.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

File Description
skillopt_sleep/skill_resolver.py Implements bounded skill resolution (normalization, root discovery, containment checks, and statusful results).
tests/test_sleep_skill_resolver.py Adds hermetic unit tests validating resolver behavior and safety properties (including symlink escape cases).
Comments suppressed due to low confidence (2)

skillopt_sleep/skill_resolver.py:137

  • If SkillResolution.candidates is meant to be immutable, resolve_skill() should return a tuple rather than reusing the mutable matches list (otherwise callers can still mutate the list via the returned object).
    if len(matches) > 1:
        return SkillResolution(
            name=normalized,
            status=AMBIGUOUS,
            candidates=matches,
            reason="several skill roots define this skill",
        )
    return SkillResolution(name=normalized, status=FOUND, path=matches[0], candidates=matches)

tests/test_sleep_skill_resolver.py:75

  • If SkillResolution.candidates is changed to a tuple for immutability, update this assertion accordingly (expect a tuple of real paths).
            self.assertEqual(res.status, AMBIGUOUS)
            self.assertEqual(res.path, "")
            self.assertEqual(res.candidates,
                             [os.path.realpath(first), os.path.realpath(second)])


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread skillopt_sleep/skill_resolver.py Outdated
Comment on lines +74 to +76
claude_home = os.path.abspath(os.path.expanduser(str(getattr(cfg, "claude_home", ""))))
if not claude_home:
return []
Comment thread skillopt_sleep/skill_resolver.py Outdated
Comment on lines +103 to +104
if os.path.commonpath([real_root, skill_file]) != real_root:
return ""
Comment thread skillopt_sleep/skill_resolver.py Outdated
name: str
status: str
path: str = ""
candidates: List[str] = field(default_factory=list)
Comment thread tests/test_sleep_skill_resolver.py Outdated
Comment on lines +120 to +124
path = _write_skill(tmp, "example-skill", "# original\n")
before = (os.stat(path).st_size, open(path, encoding="utf-8").read())
resolve_skill("example-skill", [tmp])
self.assertEqual((os.stat(path).st_size,
open(path, encoding="utf-8").read()), before)
Comment thread tests/test_sleep_skill_resolver.py Outdated
res = resolve_skill("example-skill", [tmp])
self.assertEqual(res.status, MISSING)
self.assertEqual(res.path, "")
self.assertEqual(res.candidates, [])
@bogdanbaciu21

Copy link
Copy Markdown
Contributor Author

Thanks for the welcome on #120. To keep review load bounded and follow the incremental shape Yif-Yang suggested, I'm closing this PR for now and will reopen it once #182 (the harvesting slice) lands or the maintainer asks for the next slice. The branch stays on my fork so this can be re-opened as-is. Happy to restructure if a different cadence is preferred.

@bogdanbaciu21 bogdanbaciu21 reopened this Aug 1, 2026
@bogdanbaciu21
bogdanbaciu21 force-pushed the skoc-004-resolve-skill-names branch from 9e66675 to b5adb82 Compare August 1, 2026 18:58

@Yif-Yang Yif-Yang 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.

Thanks for keeping this as a small, read-only slice. I rechecked the current head, including an independent Claude Code + Opus 5.0 pass. The direction is useful, but three filesystem-contract issues should be fixed before we expose this resolver:

  • An absent or empty claude_home is passed through abspath(""), which turns it into the current working directory. That can accidentally trust /skills. Please validate the raw value before expanduser/abspath and return no roots when it is empty.
  • os.path.commonpath() can raise ValueError for unrelated roots/drives on Windows. _contained_skill_file() should treat that as not contained rather than letting resolution fail.
  • Cache discovery currently stops at ///skills. Claude marketplace installations can have a version directory, ////skills>, so the resolver can miss the installed skill it is intended to find. Please derive the supported layout from a real install (or support both bounded layouts) rather than assuming one depth.

All current tests use synthetic temporary directories. Please add one path-layout smoke based on an actual marketplace-installed Claude plugin; a sanitized fixture matching the observed tree is fine. This is not a request for a model benchmark—the important experiment here is validating the real filesystem contract.

devin-ai-integration Bot and others added 2 commits August 2, 2026 22:23
Add a read-only resolver that normalizes an untrusted skill name, matches it
only inside documented skill roots, refuses traversal and symlink escapes, and
distinguishes found, missing, ambiguous, and rejected outcomes.

Refs microsoft#120
Five points from review on microsoft#185, plus one adjacent hardening:

- skill_search_roots: guard the blank claude_home BEFORE abspath.
  os.path.abspath("") returns the CWD, so a config override of
  claude_home="" turned the working directory into a skill root. The
  existing `if not claude_home` check could never fire because it ran
  on the already-absolutised value.
- _contained_skill_file: os.path.commonpath raises ValueError when the
  resolved skill file lands on a different drive (Windows, reachable via
  symlink). Treat it as "not contained" rather than letting it crash
  resolution.
- SkillResolution.candidates is now Tuple[str, ...] with a () default, so
  the frozen dataclass is actually immutable; callers can no longer mutate
  the result in place.
- Tests use context managers instead of open(...).read(), so no descriptor
  is left open (matters on Windows, where an open handle blocks delete).
- Test assertions updated for the tuple.

Adjacent: plugin-cache discovery now goes through a _listdir helper that
swallows OSError, so an unreadable cache directory degrades to "no plugin
roots" instead of raising. Previously os.path.isdir() passing did not
guarantee os.listdir() would succeed.

New tests cover each fix: blank/whitespace/None claude_home, an unreadable
plugin cache, tuple immutability, and a commonpath that raises ValueError.
Copilot AI review requested due to automatic review settings August 2, 2026 19:20
@bogdanbaciu21
bogdanbaciu21 force-pushed the skoc-004-resolve-skill-names branch from b5adb82 to 719c74c Compare August 2, 2026 19:20

Copilot AI 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.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (4)

tests/test_sleep_skill_resolver.py:107

  • This test depends on creating a symlinked SKILL.md, which can raise on systems where symlinks aren’t supported or allowed. Skipping in that case keeps the suite portable while still validating the security behavior where symlinks exist.
            os.symlink(elsewhere, os.path.join(root, "example-skill", "SKILL.md"))

tests/test_sleep_skill_resolver.py:115

  • Creating a symlinked root may fail on some Windows environments due to symlink privilege restrictions. Consider skipping this test when os.symlink isn’t available/allowed so Windows runs don’t fail spuriously.
            os.symlink(real, link)

tests/test_sleep_skill_resolver.py:196

  • Using chmod 0o000 to simulate an unreadable directory is not reliable cross-platform (especially on Windows, where permissions behave differently). Mocking os.listdir for the cache path makes this test deterministic while still exercising the error-handling path in _listdir().
            os.chmod(cache, 0o000)
            try:
                cfg = load_config(claude_home=claude_home)
                self.assertEqual(skill_search_roots(cfg), [skills])
            finally:
                os.chmod(cache, 0o700)

tests/test_sleep_skill_resolver.py:97

  • These symlink-based assertions will fail on platforms/environments where os.symlink is unavailable or requires elevated privileges (notably Windows CI without Developer Mode). It’d be more robust to skip this test when symlink creation isn’t permitted, rather than hard-failing the suite.

This issue also appears in the following locations of the same file:

  • line 107
  • line 115
            os.symlink(os.path.join(outside, "example-skill"),
                       os.path.join(root, "example-skill"))

@Yif-Yang

Yif-Yang commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Thanks for the quick revision. The empty claude_home and cross-drive commonpath() issues are now fixed, and the tuple/OSError hardening is useful as well. I also reran the focused suite: all 21 resolver tests pass.

One requested filesystem-contract item is still outstanding: skill_search_roots() only checks <cache>/<marketplace>/<plugin>/skills, so it does not discover the versioned marketplace layout <cache>/<marketplace>/<plugin>/<version>/skills. The current path-layout test also exercises only the unversioned synthetic layout.

Could you support the bounded versioned layout (while retaining the legacy layout if needed) and add a sanitized fixture or smoke test that mirrors an actual installed plugin tree? Once that is covered, we can re-review this small slice quickly.

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