Skip to content

fix: prevent deletion of instance-level skills via agent API - #400

Merged
jamiepine merged 2 commits into
spacedriveapp:mainfrom
l33t0:fix/skills-remove-instance-guard
Mar 12, 2026
Merged

fix: prevent deletion of instance-level skills via agent API#400
jamiepine merged 2 commits into
spacedriveapp:mainfrom
l33t0:fix/skills-remove-instance-guard

Conversation

@l33t0

@l33t0 l33t0 commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Summary

DELETE /agents/skills/remove was deleting instance-level skills (shared across all agents) without checking the skill's source. Any agent could permanently remove a skill that all agents depend on.

  • SkillSet::remove() now checks skill.source and rejects removal of Instance-level skills with a clear error message
  • The API endpoint returns 403 Forbidden when attempting to remove an instance skill (previously would have returned 500 or succeeded)
  • Workspace-level skills can still be removed normally

Also fixes a pre-existing clippy collapsible_if lint in the Signal adapter code.

Closes #365

Test plan

  • cargo clippy --all-targets -- -D warnings passes clean
  • All 11 skills tests pass, including 4 new tests:
    • remove_instance_skill_is_rejected - verifies instance skills can't be removed
    • remove_workspace_skill_succeeds - verifies workspace skills still work
    • remove_nonexistent_skill_returns_none - verifies missing skill handling
    • remove_is_case_insensitive - verifies case-insensitive name matching
  • Manually test removing an instance skill via the admin UI and verify 403 response

Note

Security fix: Prevents accidental deletion of instance-level skills through the agent API. The SkillSet::remove() method now validates skill source and rejects removal attempts for instance-level skills with a clear error. API endpoint correctly returns 403 Forbidden instead of 500 or allowing removal. Also includes clippy lint cleanup in Signal adapter code.

Written by Tembo for commit 6b5347d. This will update automatically on new commits.

SkillSet::remove() now rejects instance-level skills with an error
instead of deleting them from disk. The API endpoint returns 403
for this case. Instance skills are shared across all agents and
should not be removable through the per-agent endpoint.

Also fixes pre-existing clippy collapsible_if lint in signal adapter.

Closes spacedriveapp#365
@coderabbitai

coderabbitai Bot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This PR fixes a security vulnerability where instance-level skills could be permanently deleted via the agent API, affecting all agents. The fix adds source-level verification in the remove method to reject instance-level skill deletions and updates API error handling to return HTTP 403 for such attempts.

Changes

Cohort / File(s) Summary
Bug Fix: Instance-Level Skill Protection
src/skills.rs, src/api/skills.rs
src/skills.rs: Added source guard to reject instance-level skill removals; only workspace skills can be deleted. Includes comprehensive test coverage for rejection, success, non-existent, and case-insensitive handling. src/api/skills.rs: Updated error handling to detect "instance-level skill" error messages and return HTTP 403 FORBIDDEN with warning log instead of HTTP 500.
Code Cleanup
src/tools/send_message_to_another_channel.rs
Refactored two conditional blocks from nested if-let constructs to use && let pattern matching, reducing nesting and improving readability without behavioral changes.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • jamiepine
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the primary change: preventing deletion of instance-level skills through the API, which directly addresses the security issue.
Description check ✅ Passed The description clearly explains the security issue, the fix implemented, and the test coverage for the changes made.
Linked Issues check ✅ Passed The PR fully addresses issue #365 by adding source validation in SkillSet::remove() to reject instance-level skill deletions and returning 403 Forbidden from the API endpoint.
Out of Scope Changes check ✅ Passed The PR includes only changes necessary to fix the security issue (#365) and a minor clippy lint fix in an unrelated file, both of which are appropriate in scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot 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.

🧹 Nitpick comments (1)
src/skills.rs (1)

182-195: Return a typed error for the instance-skill guard.

This policy is currently encoded in an anyhow! string, which forces src/api/skills.rs to detect the 403 case by substring-matching the message. If this text changes, the API will quietly fall back to 500. A dedicated error variant for “instance-level skill cannot be removed here” would make the authorization behavior stable across callers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/skills.rs` around lines 182 - 195, Replace the ad-hoc anyhow::bail string
in Skills::remove with a typed error variant: introduce a new error enum variant
(e.g., RemoveSkillError::InstanceSkillNotRemovable or add InstanceSkillRemoval
to the existing error type used by skills operations) and return that variant
when skill.source == SkillSource::Instance from the remove method; update
callers (notably the API layer in src/api/skills.rs) to detect this specific
error variant instead of substring-matching the message so instance-level
removal yields a stable 403 handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/skills.rs`:
- Around line 182-195: Replace the ad-hoc anyhow::bail string in Skills::remove
with a typed error variant: introduce a new error enum variant (e.g.,
RemoveSkillError::InstanceSkillNotRemovable or add InstanceSkillRemoval to the
existing error type used by skills operations) and return that variant when
skill.source == SkillSource::Instance from the remove method; update callers
(notably the API layer in src/api/skills.rs) to detect this specific error
variant instead of substring-matching the message so instance-level removal
yields a stable 403 handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dea34f14-d836-4434-a756-b7f0ef4d65c5

📥 Commits

Reviewing files that changed from the base of the PR and between 0a97964 and 6b5347d.

📒 Files selected for processing (3)
  • src/api/skills.rs
  • src/skills.rs
  • src/tools/send_message_to_another_channel.rs

Comment thread src/api/skills.rs
@@ -252,8 +252,14 @@ pub(super) async fn remove_skill(
crate::skills::SkillSet::load(&instance_skills_dir, &workspace_skills_dir).await;

let removed_path = skills.remove(&req.name).await.map_err(|error| {

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.

String-matching on anyhow text to decide 403 feels brittle. Since you already loaded the SkillSet, you can check skill.source directly and keep the status mapping independent of the exact error message.

Suggested change
let removed_path = skills.remove(&req.name).await.map_err(|error| {
if let Some(skill) = skills.get(&req.name) {
if skill.source == crate::skills::SkillSource::Instance {
tracing::warn!(skill = %req.name, "rejected removal of instance-level skill");
return Err(StatusCode::FORBIDDEN);
}
}
let removed_path = skills.remove(&req.name).await.map_err(|error| {
tracing::warn!(%error, skill = %req.name, "failed to remove skill");
StatusCode::INTERNAL_SERVER_ERROR
})?;

Comment thread src/skills.rs
Skill {
name: name.into(),
description: format!("{name} skill"),
file_path: PathBuf::from(format!("/skills/{name}/SKILL.md")),

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.

Minor: hardcoding /tmp makes this test helper less portable (e.g. Windows, sandboxed runners). std::env::temp_dir() keeps it platform-friendly.

Suggested change
file_path: PathBuf::from(format!("/skills/{name}/SKILL.md")),
base_dir: std::env::temp_dir().join(format!("test-skills-{}", uuid::Uuid::new_v4())),

@jamiepine
jamiepine enabled auto-merge March 12, 2026 09:48
@jamiepine
jamiepine disabled auto-merge March 12, 2026 10:38
@jamiepine
jamiepine merged commit 788b26c into spacedriveapp:main Mar 12, 2026
3 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.

Bug: /agents/skills/remove deletes instance-level skills affecting all agents

2 participants