Skip to content

fix: pack-scoped skill ids, dangling-link repair, session-scoped skills via SDK plugins - #336

Merged
saucam merged 2 commits into
mainfrom
fix/pack-skill-scope
Sep 14, 2026
Merged

saucam merged 2 commits into
mainfrom
fix/pack-skill-scope

Conversation

@saucam

@saucam saucam commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

Auditing why the only pipeline run to date died at phase six (Unknown command: /adversary) surfaced three defects in how a pack's skills reach a session:

  1. Registry collisions. Skill and gate registries were daemon-wide and keyed by bare id. Installing a second pack that also declared review, ship, or tests_pass replaced the first pack's entries last-wins (the boot log says skill "review" already registered — overwriting), and a run from the first pack drove the second pack's skill.
  2. Dangling links never healed. An older loader linked registry skills from a temp clone under /tmp. After the cache moved, existsSync was false for those links, so #linkSkills tried to create them, hit EEXIST, warned, and left the skill broken on every install and trust thereafter.
  3. Skills were machine-wide only. A trusted pack's skills were symlinked into ~/.claude/skills, where every Claude Code session on the machine discovered them. On a machine whose ~/.claude is owned by something else (an org bundle linked by hand), that is the wrong scope. Subagents, constitution, and role were already session-scoped; skills were the exception.

Change

  • scoped.ts: packs register skills and gates under <packId>/<id>. The engine, the skill phase kind, and create-time validation resolve the run's own pack entry first and fall back to the bare id, so built-in gates (always, manual) and directly registered entries behave exactly as before. Phase defs keep the ids as authored; CLI output and the Pack Browser are unchanged.
  • #linkSkills repairs a dangling symlink in place. A real directory or a live link is still never touched.
  • pipeline.skillScope (global default, session opt-in; env CODEOID_PIPELINE_SKILL_SCOPE). Under session nothing is linked; PackService synthesizes ~/.codeoid/plugins/<registry>/ (plugin manifest + a real skills/ holding one symlink per real skill directory, under the same lstat guard as global linking), resolveActivation() carries it as skillsPluginDir, Session passes it per turn as TurnOpts.pluginDirs, and the Claude provider hands it to the SDK plugins option inside the existing rebuild guard. Collaboration adoption carries it to the orchestrator and role-children. The read sandbox and skill-command grant scan include the plugin tier. Same trust rule as linking. Non-Claude backends ignore it, as they already ignore pack subagents.
  • pipeline.pack.list reports the daemon's live skillScope (optional, additive field); codeoid pack list prints it.
  • Docs: docs/pack-loading.md §3a; CHANGELOG under Unreleased (Added + Fixed).

Behaviour change to note

An explicit-phases plan (wire pipeline.create with phases, not pack) can no longer borrow an installed pack's skill or gate by its bare id. That borrowing was the same cross-pack leakage from the other side. Name the entry by its qualified id (skill: "org-dev/spec") or create with pack; the create error now lists the qualified ids that exist. The web UI and CLI always send pack, so only direct API/SDK clients are affected.

Audit (second commit)

An independent adversarial review of the first commit plus a trace of every registry consumer and activation site found, and this PR now fixes:

  • High: the plugin layout linked the whole <cache>/skills dir, dropping the symlink guard the global path has. Reproduced: a registry skills/evil -> <dir> was discovered as a skill and its target's parent landed in additionalDirectories. Now per-entry links under the same guard, with pruning of stale links.
  • Medium: collaboration adoption dropped skillsPluginDir for the orchestrator and role-children. Now carried through compileGoalPack's activation and roleChildPosture.
  • Medium: the explicit-phases behaviour change above was undocumented and the error was a flat "unknown". Now documented, with a helpful create error and a test.
  • Low: env override, skillScope on the pack list, a stale comment, and coverage for the prune / operator-dir / hostile-entry branches.

Verification

  • bun x tsc --noEmit on the CLI, protocol, and core packages: clean.
  • bun run lint: clean.
  • bun run test: 2507 pass, 12 skip, 0 fail (11 new tests across pack-service, pack, skill-kind, collaboration, config, and the Claude provider helpers).
  • Live probes against Claude Code 2.1.265 / Agent SDK 0.3.258: a local plugin's skills resolve both bare (/spec) and namespaced (/<registry>:spec); a plugin whose skills/ holds per-entry symlinks is discovered; on a bare-name collision the project/user-tier skill wins and the namespaced form selects the plugin's.

Operator notes

  • Default behaviour is unchanged. To opt a machine into session scope, add "skillScope": "session" under pipeline in ~/.codeoid/config.json once the daemon runs this code (an older daemon strips unknown keys on its next config write), then remove any existing ~/.claude/skills/* links that point into ~/.codeoid/packs/.

🤖 Generated with Claude Code

…ls via SDK plugins

Three changes to how a pack's skills reach a session, found while
auditing why the only pipeline run to date died at its sixth phase with
"Unknown command: /adversary".

**Pack-scoped registry ids.** The daemon-wide skill/gate registries were
keyed by bare id, so installing a second pack that also declared
`review`, `ship`, or `tests_pass` replaced the first pack's entries
last-wins — the boot log announced it — and a run from the first pack
drove the second pack's skill. Packs now register under `<packId>/<id>`
(new `scoped.ts`); the engine, the skill phase kind, and create-time
validation resolve the run's own pack entry first and fall back to the
bare id, which is how built-in gates (`always`, `manual`) and
explicit-`phases` plans keep working unchanged. Phase defs keep the ids
as authored, so CLI output and the Pack Browser are untouched.

**Dangling-link repair.** An earlier loader linked registry skills from a
temp clone under `/tmp`; after the cache moved to `~/.codeoid/packs/`
those links pointed at nothing. `existsSync` is false for a dangling
symlink, so `#linkSkills` tried to create it, hit EEXIST, warned, and
left the skill broken on every install and trust thereafter. A dangling
link is now unlinked and relinked; a real directory or a live link is
still never touched.

**Session-scoped skills.** Until now a trusted pack's skills reached a
session one way: symlinked into `~/.claude/skills`, visible to every
Claude Code session on the machine. That is the wrong scope for a
machine whose `~/.claude` is owned by something else. New config
`pipeline.skillScope` (`global` default, `session` opt-in): under
`session` nothing is linked; PackService synthesizes a
Claude-Code-plugin-shaped dir per registry (`~/.codeoid/plugins/<name>/`
with a manifest and `skills → <cache>/skills`), `resolveActivation()`
carries it as `skillsPluginDir`, Session passes it per turn as
`TurnOpts.pluginDirs`, and the Claude provider hands it to the SDK's
`plugins` option — inside the same rebuild guard as the prompt append
and skill grants, since a pipeline swaps activations between phases.
The read sandbox and the skill-command grant scan include the plugin
tier so `!`…`` substitutions keep working. Same trust rule as linking.

Verified against the real binary: a local plugin's skills resolve both
bare (`/spec`) and namespaced (`/<registry>:spec`), and a plugin whose
`skills` entry is a symlink is discovered — the exact layout synthesized
here — so pack `command:` values need no rewrite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Yash Datta <yd2590@columbia.edu>
@saucam

saucam commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

pr-shepherd summary (iteration 1) — no changes pushed.

  • CI at 3393805: daemon-check SUCCESS, web-check SUCCESS, Socket Security Project Report SUCCESS, Socket Security Pull Request Alerts SUCCESS.
  • Branch hygiene: 1 commit ahead of main, 0 behind; single author; DCO Signed-off-by present; subject uses the fix: prefix.
  • Local reproduction (worktree): bun run lint, bun run check:versions, bun run typecheck all pass; CI's daemon-check covered test:coverage and build.
  • gemini-code-assist[bot]: no review on this PR. The bot has not reviewed any PR in this repo since devops: publish from the Highflame org as @highflame/codeoid, 0.4.0 #269 (2026-08-06), so none is expected.
  • Slack review request: skipped — the Slack MCP server is not authorized in this environment.

Ready for the owner to review and merge.

Audit of the first commit (an independent review pass plus a trace of
every registry consumer and activation site) found one hole and two
gaps; all three are closed here, with tests.

**The plugin layout dropped the symlink guard the global path has.**
`#ensureSkillPlugin` linked the WHOLE `<cache>/skills` directory into
the plugin, while `#linkSkills` deliberately refuses a `skills/<name>`
that is itself a symlink. Reproduced: a registry shipping
`skills/evil -> /some/dir` was discovered as a skill, and — because
`skillSandboxDirs` widens the read sandbox to the real parent of every
entry — its target's parent landed in `additionalDirectories`, and its
`!`…`` substitutions in `allowedTools`. The session scope was strictly
weaker than the global one. The plugin's `skills/` is now a real
directory holding one symlink per real skill directory, under the same
lstat guard; stale links (a skill removed on `registry refresh`, or one
pointing outside the cache) are pruned on the next activation, a
whole-dir link from the pre-release layout is replaced, and a real
directory an operator placed there is left alone.

**Collaboration adoption lost the plugin.** The compiled goal pack was
built from `adoption.subagents` but not `adoption.skillsPluginDir`, and
`roleChildPosture` never carried it, so under `session` scope a
`--pack` collaboration's orchestrator and role-children had none of the
pack's slash skills while a global-scope adoption saw them all. Both
now carry it (children on spawn; the resume path already re-derives no
adoption, unchanged here).

**Explicit-`phases` plans could borrow a pack's entries by bare id.**
Packs no longer register bare ids, so an explicit plan naming `spec` or
`tests_pass` now fails at create — that borrowing was the same
cross-pack leakage from the other side, and the web UI and CLI always
send `pack`. Made deliberate rather than accidental: the create error
lists the qualified ids that exist (`"org-dev/spec"`), which an explicit
plan can name directly; CHANGELOG and docs say so.

Also: `CODEOID_PIPELINE_SKILL_SCOPE` env override for sandbox/CI
images; `pipeline.pack.list` reports the daemon's live `skillScope` and
`codeoid pack list` prints it; docs record the bare-name collision rule
(a user- or project-tier skill wins bare `/spec`, verified against the
binary; `/<registry>:spec` names the pack's).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Yash Datta <yd2590@columbia.edu>
@saucam
saucam merged commit c360d20 into main Sep 14, 2026
4 checks passed
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