Context
Follow-up to #506 (task #36). The local skill inventory (GET /agents/:id/skills/local, daemon/src/skills/local-skill-inventory.ts) scans the agent's workspace skill roots for SKILL.md metadata. #506 already hardened it: symlink confinement via realpath, O_NOFOLLOW + before/opened/after fstat identity + hard-link rejection (readBoundedFile), a leaf skill-directory inode before/after check, a scanned-entry cap, and a serialized-byte budget under the control-frame limit.
Residual (owner-accepted P2)
An ancestor-directory ABA swap remains: a same-UID racing process can rename an ancestor (e.g. .claude or the skills root) after the realpath() containment check, temporarily replace it with a symlink to a peer workspace while the manifest is read, then restore the original ancestor before the leaf before/after check. The leaf directory moves with its ancestor, so its device/inode/ctime/mtime are unchanged, and the outside manifest is returned. Closing this fully needs directory-descriptor / openat-style traversal or a read sandbox confined to the workspace realpath — neither expressible with Node's fs path API (no openat/fstatat).
Why deferred (P2)
- Exposure is low sensitivity: skill
name + description metadata only.
- The attacker must already be a same-UID process racing the daemon read.
- The existing shipped
GET /agents/:id/workspace/file byte-read (cp/workspace-reader.ts) uses the same lexical + realpath containment and carries the same ABA residual — the skill endpoint is already more hardened than that precedent. A general fix likely belongs at the shared workspace-read layer.
Options
- A shared TOCTOU-safe traversal primitive (native
openat/fstatat binding or a vetted dependency), used by both the workspace-file reader and the skill inventory.
- Run untrusted workspace reads inside the OS sandbox (SRT/bwrap) confined to the workspace realpath.
Acceptance
- Ancestor-swap during a local-skill read cannot surface another workspace's manifest.
- Applies consistently to the existing workspace-file byte read.
Refs: #506, packages/daemon/src/skills/local-skill-inventory.ts, packages/daemon/src/cp/workspace-reader.ts.
Context
Follow-up to #506 (task #36). The local skill inventory (
GET /agents/:id/skills/local,daemon/src/skills/local-skill-inventory.ts) scans the agent's workspace skill roots forSKILL.mdmetadata. #506 already hardened it: symlink confinement via realpath,O_NOFOLLOW+ before/opened/afterfstatidentity + hard-link rejection (readBoundedFile), a leaf skill-directory inode before/after check, a scanned-entry cap, and a serialized-byte budget under the control-frame limit.Residual (owner-accepted P2)
An ancestor-directory ABA swap remains: a same-UID racing process can rename an ancestor (e.g.
.claudeor theskillsroot) after therealpath()containment check, temporarily replace it with a symlink to a peer workspace while the manifest is read, then restore the original ancestor before the leaf before/after check. The leaf directory moves with its ancestor, so its device/inode/ctime/mtime are unchanged, and the outside manifest is returned. Closing this fully needs directory-descriptor /openat-style traversal or a read sandbox confined to the workspace realpath — neither expressible with Node'sfspath API (noopenat/fstatat).Why deferred (P2)
name+descriptionmetadata only.GET /agents/:id/workspace/filebyte-read (cp/workspace-reader.ts) uses the same lexical + realpath containment and carries the same ABA residual — the skill endpoint is already more hardened than that precedent. A general fix likely belongs at the shared workspace-read layer.Options
openat/fstatatbinding or a vetted dependency), used by both the workspace-file reader and the skill inventory.Acceptance
Refs: #506,
packages/daemon/src/skills/local-skill-inventory.ts,packages/daemon/src/cp/workspace-reader.ts.