use axum::{
Json,
extract::{Path, Query, State},
response::IntoResponse,
};
use serde::Deserialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::path::Path as FsPath;
use std::sync::OnceLock;
use super::{AppState, JsonError, bad_request, internal_err, not_found};
#[derive(Debug, Clone, Deserialize)]
struct RegistryManifest {
version: String,
packs: RegistryPacks,
}
#[derive(Debug, Clone, Deserialize)]
struct RegistryPacks {
skills: RegistrySkillPack,
}
#[derive(Debug, Clone, Deserialize)]
struct RegistrySkillPack {
path: String,
files: std::collections::HashMap<String, String>,
}
#[derive(Debug, Deserialize)]
pub struct CatalogQuery {
#[serde(default)]
pub q: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CatalogInstallRequest {
pub skills: Vec<String>,
#[serde(default)]
pub activate: bool,
}
#[derive(Debug, Deserialize)]
pub struct CatalogActivateRequest {
pub skills: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct BuiltinSkillRecord {
name: String,
description: String,
}
const BUILTIN_SKILLS_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/builtin-skills.json"));
fn builtin_skills() -> &'static Vec<BuiltinSkillRecord> {
static BUILTIN_SKILLS: OnceLock<Vec<BuiltinSkillRecord>> = OnceLock::new();
BUILTIN_SKILLS.get_or_init(|| match serde_json::from_str(BUILTIN_SKILLS_JSON) {
Ok(skills) => skills,
Err(e) => {
tracing::error!(error = %e, "builtin skill registry JSON is invalid");
Vec::new()
}
})
}
fn is_builtin_skill_name(name: &str) -> bool {
builtin_skills()
.iter()
.any(|skill| skill.name.eq_ignore_ascii_case(name))
}
fn is_builtin_skill(s: &ironclad_db::skills::SkillRecord) -> bool {
s.kind.eq_ignore_ascii_case("builtin") || is_builtin_skill_name(&s.name)
}
fn canonical_in_root(root: &FsPath, base: &FsPath, raw: &FsPath) -> Result<String, String> {
let candidate = if raw.is_absolute() {
raw.to_path_buf()
} else {
base.join(raw)
};
let canonical = std::fs::canonicalize(&candidate).map_err(|e| {
format!(
"script path '{}' cannot be resolved: {e}",
candidate.display()
)
})?;
if !canonical.starts_with(root) {
return Err(format!(
"script path '{}' escapes skills_dir '{}'",
canonical.display(),
root.display()
));
}
if !canonical.is_file() {
return Err(format!(
"script path '{}' is not a file",
canonical.display()
));
}
Ok(canonical.to_string_lossy().to_string())
}
fn validate_policy_overrides(value: &serde_json::Value) -> Result<(), String> {
let obj = value
.as_object()
.ok_or_else(|| "policy_overrides must be a JSON object".to_string())?;
let allowed = ["require_creator", "deny_external", "disabled"];
for (k, v) in obj {
if !allowed.contains(&k.as_str()) {
return Err(format!("unsupported policy_overrides key '{k}'"));
}
if !v.is_boolean() {
return Err(format!(
"policy_overrides key '{}' must be boolean, got {}",
k, v
));
}
}
Ok(())
}
fn normalize_risk_level(raw: &str) -> Result<&'static str, String> {
match raw.to_ascii_lowercase().as_str() {
"safe" => Ok("Safe"),
"caution" => Ok("Caution"),
"dangerous" => Ok("Dangerous"),
"forbidden" => Ok("Forbidden"),
_ => Err(format!("invalid risk_level '{raw}'")),
}
}
fn registry_base_url(manifest_url: &str) -> String {
if let Some(pos) = manifest_url.rfind('/') {
manifest_url[..pos].to_string()
} else {
manifest_url.to_string()
}
}
async fn fetch_catalog_manifest(state: &AppState) -> Result<(RegistryManifest, String), String> {
let config = state.config.read().await;
let registry_url = config.update.registry_url.clone();
drop(config);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("catalog client init failed: {e}"))?;
let resp = client
.get(®istry_url)
.send()
.await
.map_err(|e| format!("catalog manifest fetch failed: {e}"))?;
if !resp.status().is_success() {
return Err(format!(
"catalog manifest endpoint returned HTTP {}",
resp.status()
));
}
let manifest: RegistryManifest = resp
.json()
.await
.map_err(|e| format!("invalid catalog manifest JSON: {e}"))?;
Ok((manifest, registry_base_url(®istry_url)))
}
fn skill_item_matches_query(name: &str, query: Option<&str>) -> bool {
if let Some(q) = query {
return name.to_ascii_lowercase().contains(&q.to_ascii_lowercase());
}
true
}
async fn reload_skills_internal(state: &AppState) -> Result<Value, JsonError> {
fn risk_level_str(r: ironclad_core::RiskLevel) -> &'static str {
match r {
ironclad_core::RiskLevel::Safe => "Safe",
ironclad_core::RiskLevel::Caution => "Caution",
ironclad_core::RiskLevel::Dangerous => "Dangerous",
ironclad_core::RiskLevel::Forbidden => "Forbidden",
}
}
let config = state.config.read().await;
let skills_dir = std::fs::canonicalize(&config.skills.skills_dir).map_err(|e| {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!(
"failed to resolve skills_dir '{}': {e}",
config.skills.skills_dir.display()
),
)
})?;
drop(config);
let loaded = ironclad_agent::skills::SkillLoader::load_from_dir(&skills_dir)
.map_err(|e| internal_err(&e))?;
let mut added = 0u32;
let mut updated = 0u32;
let mut rejected = 0u32;
let mut issues: Vec<String> = Vec::new();
let existing_by_name: std::collections::HashMap<String, ironclad_db::skills::SkillRecord> =
ironclad_db::skills::list_skills(&state.db)
.inspect_err(|e| tracing::error!(error = %e, "failed to list skills during sync"))
.unwrap_or_default()
.into_iter()
.map(|s| (s.name.clone(), s))
.collect();
for skill in &loaded {
let name = skill.name();
let hash = skill.hash();
let kind = match skill {
ironclad_agent::skills::LoadedSkill::Structured(_, _, _) => "structured",
ironclad_agent::skills::LoadedSkill::Instruction(_, _, _) => "instruction",
};
let triggers = serde_json::to_string(skill.triggers()).ok();
let source = skill.source_path().to_string_lossy().to_string();
let desc = skill.description();
let (tool_chain_json, policy_overrides_json, script_path, risk_level) = if let Some(
manifest,
) =
skill.structured_manifest()
{
if manifest
.tool_chain
.as_ref()
.is_some_and(|chain| !chain.is_empty())
{
rejected += 1;
issues.push(format!(
"rejected skill '{}': tool_chain is not yet executable in runtime (remove it or keep empty)",
name
));
continue;
}
let tool_chain_json = manifest
.tool_chain
.as_ref()
.and_then(|v| serde_json::to_string(v).ok());
let policy_overrides_json = if let Some(v) = manifest.policy_overrides.as_ref() {
if let Err(msg) = validate_policy_overrides(v) {
rejected += 1;
issues.push(format!("rejected skill '{}': {}", name, msg));
continue;
}
serde_json::to_string(v).ok()
} else {
None
};
let base = skill.source_path().parent().unwrap_or(skills_dir.as_path());
let script_path = manifest
.script_path
.as_ref()
.map(|p| canonical_in_root(&skills_dir, base, p))
.transpose()
.map_err(|msg| {
rejected += 1;
issues.push(format!("rejected skill '{}': {}", name, msg));
(
axum::http::StatusCode::BAD_REQUEST,
"invalid skill manifest".to_string(),
)
})
.ok()
.flatten();
if manifest.script_path.is_some() && script_path.is_none() {
continue;
}
(
tool_chain_json,
policy_overrides_json,
script_path,
risk_level_str(manifest.risk_level).to_string(),
)
} else {
(None, None, None, "Caution".to_string())
};
let existing = existing_by_name.get(name);
let version = skill.version();
let author = skill.author();
if let Some(existing) = existing {
if existing.content_hash != hash
|| existing.triggers_json.as_deref() != triggers.as_deref()
|| existing.tool_chain_json.as_deref() != tool_chain_json.as_deref()
|| existing.policy_overrides_json.as_deref() != policy_overrides_json.as_deref()
|| existing.script_path.as_deref() != script_path.as_deref()
|| existing.source_path != source
|| existing.risk_level != risk_level
|| existing.version != version
|| existing.author != author
{
match ironclad_db::skills::update_skill_with_provenance(
&state.db,
&existing.id,
hash,
triggers.as_deref(),
tool_chain_json.as_deref(),
policy_overrides_json.as_deref(),
script_path.as_deref(),
&source,
&risk_level,
Some(version),
Some(author),
None, ) {
Ok(_) => updated += 1,
Err(e) => {
tracing::warn!(error = %e, skill = name, "skill sync: update_skill failed");
}
}
}
} else {
match ironclad_db::skills::register_skill_with_provenance(
&state.db,
name,
kind,
desc,
&source,
hash,
triggers.as_deref(),
tool_chain_json.as_deref(),
policy_overrides_json.as_deref(),
script_path.as_deref(),
&risk_level,
version,
author,
"local", ) {
Ok(_) => added += 1,
Err(e) => {
tracing::warn!(error = %e, skill = name, "skill sync: register_skill failed");
}
}
}
}
Ok(serde_json::json!({
"reloaded": true,
"scanned": loaded.len(),
"added": added,
"updated": updated,
"rejected": rejected,
"issues": issues,
}))
}
pub async fn list_skills(State(state): State<AppState>) -> impl IntoResponse {
match ironclad_db::skills::list_skills(&state.db) {
Ok(skills) => {
let mut items: Vec<Value> = skills
.into_iter()
.map(|s| {
let built_in = is_builtin_skill(&s);
serde_json::json!({
"id": s.id,
"name": s.name,
"kind": s.kind,
"description": s.description,
"risk_level": s.risk_level,
"enabled": s.enabled || built_in,
"built_in": built_in,
"last_loaded_at": s.last_loaded_at,
"created_at": s.created_at,
"version": s.version,
"author": s.author,
"registry_source": s.registry_source,
})
})
.collect();
let seen: HashSet<String> = items
.iter()
.filter_map(|item| item.get("name").and_then(|v| v.as_str()))
.map(|name| name.to_ascii_lowercase())
.collect();
for built_in in builtin_skills() {
if seen.contains(&built_in.name.to_ascii_lowercase()) {
continue;
}
items.push(serde_json::json!({
"id": format!("builtin:{}", built_in.name),
"name": built_in.name,
"kind": "builtin",
"description": built_in.description,
"risk_level": "Caution",
"enabled": true,
"built_in": true,
"last_loaded_at": Value::Null,
"created_at": Value::Null,
}));
}
Ok(axum::Json(serde_json::json!({ "skills": items })))
}
Err(e) => Err(internal_err(&e)),
}
}
pub async fn get_skill(State(state): State<AppState>, Path(id): Path<String>) -> impl IntoResponse {
match ironclad_db::skills::get_skill(&state.db, &id) {
Ok(Some(s)) => {
let built_in = is_builtin_skill(&s);
Ok(axum::Json(serde_json::json!({
"id": s.id,
"name": s.name,
"kind": s.kind,
"description": s.description,
"content_hash": s.content_hash,
"triggers_json": s.triggers_json,
"tool_chain_json": s.tool_chain_json,
"policy_overrides_json": s.policy_overrides_json,
"risk_level": s.risk_level,
"enabled": s.enabled || built_in,
"built_in": built_in,
"last_loaded_at": s.last_loaded_at,
"created_at": s.created_at,
"version": s.version,
"author": s.author,
"registry_source": s.registry_source,
})))
}
Ok(None) => Err(not_found(format!("skill {id} not found"))),
Err(e) => Err(internal_err(&e)),
}
}
pub async fn reload_skills(State(state): State<AppState>) -> Result<impl IntoResponse, JsonError> {
let payload = reload_skills_internal(&state).await?;
Ok(axum::Json(payload))
}
pub async fn catalog_list(
State(state): State<AppState>,
Query(query): Query<CatalogQuery>,
) -> impl IntoResponse {
let q = query.q.as_deref();
let mut items = Vec::<Value>::new();
let builtin_names: HashSet<String> = builtin_skills()
.iter()
.map(|skill| skill.name.to_ascii_lowercase())
.collect();
for built_in in builtin_skills() {
if skill_item_matches_query(&built_in.name, q) {
items.push(serde_json::json!({
"name": built_in.name,
"kind": "builtin",
"description": built_in.description,
"source": "builtin",
}));
}
}
if let Ok((manifest, _base_url)) = fetch_catalog_manifest(&state).await {
for (filename, sha256) in &manifest.packs.skills.files {
let name = filename.strip_suffix(".md").unwrap_or(filename).to_string();
if builtin_names.contains(&name.to_ascii_lowercase()) {
continue;
}
if skill_item_matches_query(&name, q) {
items.push(serde_json::json!({
"name": name,
"kind": "instruction",
"source": "registry",
"filename": filename,
"sha256": sha256,
"version": manifest.version,
}));
}
}
}
axum::Json(serde_json::json!({ "items": items }))
}
pub async fn catalog_install(
State(state): State<AppState>,
Json(req): Json<CatalogInstallRequest>,
) -> Result<impl IntoResponse, JsonError> {
if req.skills.is_empty() {
return Err(bad_request("skills list is required"));
}
let (manifest, base_url) = fetch_catalog_manifest(&state).await.map_err(|e| {
(
axum::http::StatusCode::BAD_GATEWAY,
format!("failed to fetch catalog: {e}"),
)
})?;
let config = state.config.read().await;
let skills_dir = config.skills.skills_dir.clone();
drop(config);
std::fs::create_dir_all(&skills_dir)
.map_err(|e| internal_err(&format!("failed to create skills dir: {e}")))?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.build()
.map_err(|e| internal_err(&format!("catalog client init failed: {e}")))?;
let selected: Vec<(&String, &String)> = manifest
.packs
.skills
.files
.iter()
.filter(|(filename, _)| {
let name = filename.strip_suffix(".md").unwrap_or(filename.as_str());
req.skills.iter().any(|s| s == name || s == *filename)
})
.collect();
if selected.is_empty() {
return Err(not_found("no matching catalog skills found"));
}
let selected_names: HashSet<String> = selected
.iter()
.map(|(filename, _)| {
filename
.strip_suffix(".md")
.unwrap_or(filename.as_str())
.to_ascii_lowercase()
})
.collect();
let builtin_collisions: Vec<String> = builtin_skills()
.iter()
.filter(|skill| selected_names.contains(&skill.name.to_ascii_lowercase()))
.map(|skill| skill.name.clone())
.collect();
if !builtin_collisions.is_empty() {
return Err(bad_request(format!(
"cannot install skills that are built-in: {}",
builtin_collisions.join(", ")
)));
}
let mut rollback_existing: Vec<(std::path::PathBuf, Vec<u8>)> = Vec::new();
let mut rollback_new: Vec<std::path::PathBuf> = Vec::new();
let mut installed: Vec<String> = Vec::new();
for (filename, expected_hash) in selected {
let url = format!("{}/{}{}", base_url, manifest.packs.skills.path, filename);
let bytes = client
.get(&url)
.send()
.await
.map_err(|e| internal_err(&format!("download failed for {filename}: {e}")))?
.bytes()
.await
.map_err(|e| internal_err(&format!("download body failed for {filename}: {e}")))?;
let actual_hash = hex::encode(Sha256::digest(&bytes));
if actual_hash != *expected_hash {
for (path, old) in rollback_existing.drain(..) {
if let Err(e) = std::fs::write(&path, old) {
tracing::warn!(error = %e, path = %path.display(), "failed to restore file during skill install rollback");
}
}
for path in rollback_new.drain(..) {
if let Err(e) = std::fs::remove_file(&path) {
tracing::warn!(error = %e, path = %path.display(), "failed to remove file during skill install rollback");
}
}
return Err(bad_request(format!("checksum mismatch for {filename}")));
}
if filename.contains('/')
|| filename.contains('\\')
|| filename.contains("..")
|| filename.starts_with('.')
{
for (path, old) in rollback_existing.drain(..) {
if let Err(e) = std::fs::write(&path, old) {
tracing::warn!(error = %e, path = %path.display(), "failed to restore file during skill install rollback");
}
}
for path in rollback_new.drain(..) {
if let Err(e) = std::fs::remove_file(&path) {
tracing::warn!(error = %e, path = %path.display(), "failed to remove file during skill install rollback");
}
}
return Err(bad_request(format!(
"invalid filename rejected: {filename}"
)));
}
let target = skills_dir.join(filename);
let target_parent = target.parent().unwrap_or(&skills_dir);
let canonical_parent = std::fs::canonicalize(target_parent)
.map_err(|e| internal_err(&format!("failed to resolve target directory: {e}")))?;
let canonical_skills_dir = std::fs::canonicalize(&skills_dir)
.map_err(|e| internal_err(&format!("failed to resolve skills_dir: {e}")))?;
if !canonical_parent.starts_with(&canonical_skills_dir) {
return Err(bad_request(format!(
"filename escapes skills directory: {filename}"
)));
}
if target.exists() {
let old = std::fs::read(&target)
.map_err(|e| internal_err(&format!("failed to backup {filename}: {e}")))?;
rollback_existing.push((target.clone(), old));
} else {
rollback_new.push(target.clone());
}
let tmp = skills_dir.join(format!(".{}.tmp", filename));
std::fs::write(&tmp, &bytes)
.map_err(|e| internal_err(&format!("failed to write temp {filename}: {e}")))?;
std::fs::rename(&tmp, &target)
.map_err(|e| internal_err(&format!("failed to install {filename}: {e}")))?;
installed.push(filename.clone());
}
let mut activation = None;
if req.activate {
match reload_skills_internal(&state).await {
Ok(payload) => activation = Some(payload),
Err(e) => {
for (path, old) in rollback_existing {
if let Err(e) = std::fs::write(&path, old) {
tracing::error!(error = %e, path = %path.display(), "rollback: failed to restore file");
}
}
for path in rollback_new {
if let Err(e) = std::fs::remove_file(&path) {
tracing::error!(error = %e, path = %path.display(), "rollback: failed to remove file");
}
}
return Err(e);
}
}
}
Ok(axum::Json(serde_json::json!({
"ok": true,
"version": manifest.version,
"installed": installed,
"activated": req.activate,
"activation": activation,
})))
}
pub async fn catalog_activate(
State(state): State<AppState>,
Json(req): Json<CatalogActivateRequest>,
) -> Result<impl IntoResponse, JsonError> {
let payload = reload_skills_internal(&state).await?;
Ok(axum::Json(serde_json::json!({
"ok": true,
"requested_skills": req.skills,
"activation": payload,
})))
}
pub async fn audit_skills(State(state): State<AppState>) -> Result<impl IntoResponse, JsonError> {
let config = state.config.read().await;
let skills_dir = std::fs::canonicalize(&config.skills.skills_dir).map_err(|e| {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!(
"failed to resolve skills_dir '{}': {e}",
config.skills.skills_dir.display()
),
)
})?;
drop(config);
let loaded = ironclad_agent::skills::SkillLoader::load_from_dir(&skills_dir)
.map_err(|e| internal_err(&e))?;
let loaded_by_name: std::collections::HashMap<
String,
(&ironclad_agent::skills::LoadedSkill, String),
> = loaded
.iter()
.map(|s| (s.name().to_string(), (s, s.hash().to_string())))
.collect();
let db_skills = ironclad_db::skills::list_skills(&state.db).map_err(|e| internal_err(&e))?;
let mut drifted = 0usize;
let mut skills_report = Vec::new();
for s in &db_skills {
let (drift_status, drift_reason) = if let Err(msg) = normalize_risk_level(&s.risk_level) {
drifted += 1;
("invalid_metadata", msg)
} else if let Some((loaded_skill, loaded_hash)) = loaded_by_name.get(&s.name) {
if &s.content_hash != loaded_hash {
drifted += 1;
(
"drifted",
format!("hash mismatch (db={} disk={})", s.content_hash, loaded_hash),
)
} else {
let mut issues = Vec::new();
if let Some(manifest) = loaded_skill.structured_manifest() {
if manifest.tool_chain.as_ref().is_some_and(|c| !c.is_empty()) {
issues.push(
"tool_chain present but runtime does not execute skill tool chains"
.to_string(),
);
}
if let Some(v) = manifest.policy_overrides.as_ref()
&& let Err(msg) = validate_policy_overrides(v)
{
issues.push(msg);
}
if let Some(script) = manifest.script_path.as_ref() {
let base = loaded_skill
.source_path()
.parent()
.unwrap_or(skills_dir.as_path());
if let Err(msg) = canonical_in_root(&skills_dir, base, script) {
issues.push(msg);
}
}
}
if issues.is_empty() {
("in_sync", String::new())
} else {
drifted += 1;
("invalid_metadata", issues.join("; "))
}
}
} else {
drifted += 1;
(
"missing_on_disk",
"present in DB but not found in skills_dir scan".to_string(),
)
};
skills_report.push(serde_json::json!({
"id": s.id,
"name": s.name,
"enabled": s.enabled,
"risk_level": s.risk_level,
"drift_status": drift_status,
"drift_reason": drift_reason,
}));
}
let tool_names: Vec<String> = state
.tools
.list()
.into_iter()
.map(|t| t.name().to_string())
.collect();
let key_tools = [
("run_script", serde_json::json!({"path":"sample.sh"})),
("read_file", serde_json::json!({"path":"README.md"})),
(
"write_file",
serde_json::json!({"path":"tmp/audit.txt","content":"x"}),
),
(
"edit_file",
serde_json::json!({"path":"tmp/audit.txt","old":"x","new":"y"}),
),
("list_directory", serde_json::json!({"path":"."})),
("glob_files", serde_json::json!({"pattern":"*.md"})),
("search_files", serde_json::json!({"query":"TODO"})),
];
let mut capability_rows = Vec::new();
for (tool_name, sample_params) in key_tools {
let Some(tool) = state.tools.get(tool_name) else {
capability_rows.push(serde_json::json!({
"tool_name": tool_name,
"present": false,
}));
continue;
};
let normal_tier = ironclad_core::SurvivalTier::Normal;
let creator_allowed = super::agent::check_tool_policy(
&state.policy_engine,
tool_name,
&sample_params,
ironclad_core::InputAuthority::Creator,
normal_tier,
tool.risk_level(),
)
.is_ok();
let external_allowed = super::agent::check_tool_policy(
&state.policy_engine,
tool_name,
&sample_params,
ironclad_core::InputAuthority::External,
normal_tier,
tool.risk_level(),
)
.is_ok();
let approval_classification = state
.approvals
.check_tool(tool_name)
.map(|c| format!("{c:?}"))
.unwrap_or_else(|e| format!("error:{e}"));
capability_rows.push(serde_json::json!({
"tool_name": tool_name,
"present": true,
"risk_level": format!("{:?}", tool.risk_level()),
"creator_allowed": creator_allowed,
"external_allowed": external_allowed,
"approval_classification": approval_classification,
}));
}
Ok(axum::Json(serde_json::json!({
"skills_dir": skills_dir,
"summary": {
"db_skills": db_skills.len(),
"disk_skills": loaded.len(),
"drifted_skills": drifted,
},
"runtime": {
"registered_tools": tool_names,
"capabilities": capability_rows,
},
"skills": skills_report,
})))
}
pub async fn toggle_skill(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, JsonError> {
let existing = ironclad_db::skills::get_skill(&state.db, &id).map_err(|e| internal_err(&e))?;
if let Some(s) = existing.as_ref()
&& is_builtin_skill(s)
{
return Err(JsonError(
axum::http::StatusCode::FORBIDDEN,
format!("skill {} is built-in and cannot be disabled", s.name),
));
}
match ironclad_db::skills::toggle_skill_enabled(&state.db, &id) {
Ok(Some(new_enabled)) => Ok(axum::Json(serde_json::json!({
"id": id,
"enabled": new_enabled,
}))),
Ok(None) => Err(not_found(format!("skill {id} not found"))),
Err(e) => Err(internal_err(&e)),
}
}
pub async fn delete_skill(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, JsonError> {
match ironclad_db::skills::get_skill(&state.db, &id) {
Ok(Some(skill)) => {
if is_builtin_skill(&skill) {
return Err(JsonError(
axum::http::StatusCode::FORBIDDEN,
format!("skill {} is built-in and cannot be deleted", skill.name),
));
}
ironclad_db::skills::delete_skill(&state.db, &id).map_err(|e| internal_err(&e))?;
Ok(axum::Json(serde_json::json!({
"id": id,
"name": skill.name,
"deleted": true,
})))
}
Ok(None) => Err(not_found(format!("skill {id} not found"))),
Err(e) => Err(internal_err(&e)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_record(name: &str, kind: &str) -> ironclad_db::skills::SkillRecord {
ironclad_db::skills::SkillRecord {
id: "s1".into(),
name: name.into(),
kind: kind.into(),
description: Some("desc".into()),
source_path: "/tmp/skill.md".into(),
content_hash: "abc".into(),
triggers_json: None,
tool_chain_json: None,
policy_overrides_json: None,
script_path: None,
risk_level: "Caution".into(),
enabled: true,
last_loaded_at: None,
created_at: "now".into(),
version: "0.0.0".into(),
author: "local".into(),
registry_source: "local".into(),
}
}
#[test]
fn builtin_catalog_loads_and_has_unique_names() {
let catalog = builtin_skills();
assert!(!catalog.is_empty());
let mut seen = std::collections::HashSet::new();
for skill in catalog {
assert!(!skill.name.trim().is_empty());
assert!(!skill.description.trim().is_empty());
assert!(
seen.insert(skill.name.to_ascii_lowercase()),
"duplicate builtin skill name: {}",
skill.name
);
}
}
#[test]
fn builtin_skill_detection_is_case_insensitive() {
assert!(is_builtin_skill_name("SELF-DIAGNOSTICS"));
assert!(!is_builtin_skill_name("scope-cli"));
assert!(is_builtin_skill(&sample_record(
"self-diagnostics",
"instruction"
)));
assert!(is_builtin_skill(&sample_record("custom", "builtin")));
assert!(!is_builtin_skill(&sample_record("custom", "instruction")));
}
#[test]
fn canonical_in_root_accepts_files_and_rejects_escape() {
let root = tempfile::tempdir().unwrap();
let nested = root.path().join("scripts");
std::fs::create_dir_all(&nested).unwrap();
let script = nested.join("run.sh");
std::fs::write(&script, "echo ok\n").unwrap();
let canonical_root = std::fs::canonicalize(root.path()).unwrap();
let canonical = canonical_in_root(
&canonical_root,
&canonical_root,
FsPath::new("scripts/run.sh"),
)
.expect("path inside root should resolve");
assert!(canonical.ends_with("run.sh"));
let outside = tempfile::NamedTempFile::new().unwrap();
let escaped = canonical_in_root(&canonical_root, &canonical_root, outside.path());
assert!(escaped.is_err());
}
#[test]
fn validate_policy_overrides_accepts_allowed_boolean_keys() {
let ok = serde_json::json!({
"require_creator": true,
"deny_external": false,
"disabled": false
});
assert!(validate_policy_overrides(&ok).is_ok());
let bad_key = serde_json::json!({"nope": true});
assert!(validate_policy_overrides(&bad_key).is_err());
let bad_type = serde_json::json!({"disabled": "yes"});
assert!(validate_policy_overrides(&bad_type).is_err());
}
#[test]
fn normalize_risk_level_canonicalizes_supported_values() {
assert_eq!(normalize_risk_level("safe").unwrap(), "Safe");
assert_eq!(normalize_risk_level("CAUTION").unwrap(), "Caution");
assert_eq!(normalize_risk_level("Dangerous").unwrap(), "Dangerous");
assert_eq!(normalize_risk_level("forbidden").unwrap(), "Forbidden");
assert!(normalize_risk_level("unknown").is_err());
}
#[test]
fn registry_base_url_and_query_matching_behave() {
assert_eq!(
registry_base_url("https://example.com/catalog/manifest.json"),
"https://example.com/catalog"
);
assert_eq!(registry_base_url("manifest.json"), "manifest.json");
assert!(skill_item_matches_query("self-diagnostics", None));
assert!(skill_item_matches_query("self-diagnostics", Some("Diag")));
assert!(!skill_item_matches_query(
"self-diagnostics",
Some("wallet")
));
}
#[test]
fn validate_policy_overrides_rejects_non_object() {
assert!(validate_policy_overrides(&serde_json::json!("not-object")).is_err());
}
#[test]
fn canonical_in_root_rejects_directory_targets() {
let root = tempfile::tempdir().unwrap();
let canonical_root = std::fs::canonicalize(root.path()).unwrap();
std::fs::create_dir_all(canonical_root.join("scripts")).unwrap();
let result = canonical_in_root(&canonical_root, &canonical_root, FsPath::new("scripts"));
assert!(result.is_err());
}
}