Skip to content

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

Open
bogdanbaciu21 wants to merge 1 commit 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 1 commit 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 on lines +74 to +76
claude_home = os.path.abspath(os.path.expanduser(str(getattr(cfg, "claude_home", ""))))
if not claude_home:
return []
Comment on lines +103 to +104
if os.path.commonpath([real_root, skill_file]) != real_root:
return ""
name: str
status: str
path: str = ""
candidates: List[str] = field(default_factory=list)
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)
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.

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
@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
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