Skip to content

Resource confinement ​

A skill's resources — its scripts/, references/, assets/, examples/ — are addressed by a path relative to the skill's own directory, and that path is not allowed to leave it. This is the load-bearing security property of the resource tier — and also its limits: confinement is defense-in-depth for file access, not a sandbox, and skills are semi-trusted content.

The rule ​

resourcePath(name, rel) (and the free function resolveResourcePath(skillDir, rel)) resolve rel against the skill directory and then check the result against that directory's real path. These are rejected with path_escape:

  • ../ traversal that climbs above the skill dir,
  • a symlink inside the skill dir whose real target lies outside it.

rel must be a non-empty relative path. An empty rel, or an absolute one, never reaches the escape check — it is rejected earlier as invalid_input. A relative rel resolves against the skill dir; the resolved absolute path is what gets checked.

ts
import { createAgentSkills, clarvisSkillRoots } from "@clarvis/agent-skills";

const skills = createAgentSkills({ roots: clarvisSkillRoots({ workspace: "/srv/project" }) });

skills.resourcePath("release", "scripts/publish.sh"); // ok — inside the skill dir
skills.resourcePath("release", "../other-skill/notes.md"); // path_escape
skills.resourcePath("release", "/etc/passwd"); // invalid_input (absolute)
skills.resourcePath("release", ""); // invalid_input (empty)

The check runs on real paths ​

Both the skill dir and the target are canonicalized with realpath before they are compared, so a symlink hop can't smuggle you out — the guard sees where the links actually point.

  • The skill dir is canonicalized first. A skill directory that is itself a symlink into a shared store (for example, with the clarvis preset, a ~/.agents/skills/<name> populated by npx skills) resolves to its real location before the containment check, so the check is against the true directory, not the link.
  • The existing prefix of the target is canonicalized too. The target need not exist yet: the guard walks up to the deepest ancestor that does exist, canonicalizes that, and re-joins the missing tail. A symlink hop anywhere in the existing prefix is therefore caught.

Two functions, two contracts ​

The confinement check is identical in both, but they differ in what else they verify:

  • resolveResourcePath(skillDir, rel) does only the path check. It returns an absolute path guaranteed inside skillDir — but does not confirm the resource exists, and does not check whether it is a file. It is the pure resolver.
  • resourcePath(name, rel) does the same check and then two more: the resource must exist (not_found if it doesn't, or if name is not a known skill) and must be a file (not_a_file if the path resolves to a directory). Use it to hand a caller a path it can open directly.
ts
import { resolveResourcePath } from "@clarvis/agent-skills";

// Pure resolver — confined, but no existence or is-file check:
resolveResourcePath("/srv/project/.clarvis/skills/release", "references/api.md");

Enumeration agrees with the guard ​

Every resource that loadSkill(name).resources lists is guaranteed to resolve under resourcePath — the enumerator applies the same boundary. As it walks the skill tree, a resource symlink whose real target escapes the skill dir is skipped with a warning, so a listed resource never turns out to be un-resolvable. Dangling symlinks are skipped with a warning too. What you can enumerate, you can resolve.

The enumerator tracks the real path of every directory it has already entered and stops when it sees one again, so a cyclic scripts/ → ../scripts (or any self-referential tree) can't send it into an infinite descent. This is a visited-set, not a depth or count cap — a legitimately deep tree enumerates in full; only an actual loop is cut.

followSymlinks defaults to true, which follows symlinked skill directories, symlinked SKILL.md manifests, and symlinked resources. Setting it to false ignores all three: a symlinked skill dir or manifest is not discovered, and symlinked resources are not enumerated. The containment guard is unchanged — turning symlinks off narrows what is reachable, not whether the boundary holds.

The limits ​

Confinement keeps a rel from wandering outside one skill's directory. It is not a sandbox and it does not isolate the process:

  • Skills are semi-trusted content. A skill's body is a block of instructions an agent may follow, and its resources may include a scripts/ directory. @clarvis/agent-skills reads and serves that content — it never runs a script and never acts on an instruction — but a consumer that surfaces a body to an LLM, or executes a bundled script, is trusting whoever authored the skill.
  • Trust your roots. You choose the discovery roots — only point them at skills you trust. If you use the clarvis preset, its cross-tool ~/.agents/skills and <ws>/.agents/skills roots are the shared store populated by external tooling (npx skills, skills.sh) and can hold third-party content; trust those accordingly.
  • Path confinement is defense-in-depth for file access, not a substitute for OS-level isolation. When an agent driven by these skills can run commands, run it inside a sandbox scoped to the project.

See the package's SECURITY.md for the full trust model.

See also ​

  • Deploy securely — concrete sandboxing guidance for the consumer
  • Error codes — path_escape, invalid_input, not_found, not_a_file

Released under the MIT License.