Scope skill mutations to caller owner
SkillsManager.update_skill walks every SKILL.md on disk and matches by
slug only; the 'owner' key in its scalar_keys whitelist meant a caller
could pass updates={'owner': 'attacker', 'description': 'pwned'} and the
first matching file on disk got silently re-owned. Two users with the
same slug under different category directories (which is supported by
the on-disk layout <category>/<name>/SKILL.md) could each stomp the
other's skill via the manage_skills tool or the in-process callers in
tool_implementations.py (edit, patch, publish, delete).
update_skill and delete_skill now require the caller's owner and only
match a file whose parsed owner field matches. The default of None
means 'no scope' and only matches ownerless skills, so an unsafe call
without an explicit owner is now a no-op. 'owner' is also removed from
scalar_keys so the updates dict cannot be used to reassign ownership
even when the manager is called from an in-process path that didn't
supply the owner argument.
The in-process callers in tool_implementations.py are updated to pass
owner=owner (which was already in scope at every call site) so the
HTTP and agent paths both go through the scoped check. The HTTP route
at routes/skills_routes.py:1499 was already owner-scoped via
sm.load(owner=user); the fix brings the in-process path up to the
same standard.
This commit is contained in:
@@ -713,7 +713,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
||||
return {"error": f"Skill {name!r} not found", "exit_code": 1}
|
||||
if not sk_new.owner:
|
||||
sk_new.owner = match.get("owner") or owner
|
||||
ok = sm.update_skill(name, _skill_dump(sk_new))
|
||||
ok = sm.update_skill(name, _skill_dump(sk_new), owner=owner)
|
||||
return {"results": f"Edited skill `{sk_new.name}`."} if ok else {"error": "Update failed", "exit_code": 1}
|
||||
|
||||
if action == "patch":
|
||||
@@ -737,7 +737,7 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
||||
except Exception as e:
|
||||
return {"error": f"Patched content is not valid SKILL.md: {e}", "exit_code": 1}
|
||||
sk_new.name = slugify(sk_new.name or name)
|
||||
ok = sm.update_skill(name, _skill_dump(sk_new))
|
||||
ok = sm.update_skill(name, _skill_dump(sk_new), owner=owner)
|
||||
return {"results": f"Patched skill `{sk_new.name}`."} if ok else {"error": "Patch update failed", "exit_code": 1}
|
||||
|
||||
if action == "publish":
|
||||
@@ -750,13 +750,13 @@ async def do_manage_skills(content: str, owner: Optional[str] = None) -> Dict:
|
||||
updates = {"status": "published"}
|
||||
if args.get("confidence") is not None:
|
||||
updates["confidence"] = max(0.0, min(1.0, float(args["confidence"])))
|
||||
sm.update_skill(name, updates)
|
||||
sm.update_skill(name, updates, owner=owner)
|
||||
return {"results": f"✅ Published `{name}`. It now appears in the skills index for future turns."}
|
||||
|
||||
if action == "delete":
|
||||
if not name:
|
||||
return {"error": "name is required for delete", "exit_code": 1}
|
||||
ok = sm.delete_skill(name)
|
||||
ok = sm.delete_skill(name, owner=owner)
|
||||
return {"results": f"Deleted skill `{name}`."} if ok else {"error": f"Skill {name!r} not found", "exit_code": 1}
|
||||
|
||||
if action == "search":
|
||||
|
||||
Reference in New Issue
Block a user