feat(skills): credential specs in skill frontmatter, HTTP tool hardening, mission leases

Skills can now declare API credentials in YAML frontmatter (SkillCredentialSpec,
SkillCredentialLocation, SkillOAuthConfig, ProviderRefreshStrategy). Valid specs
are registered into SharedCredentialRegistry at startup; the HttpTool auto-injects
credentials for matching hosts — same zero-exposure model as WASM tools.

HTTP tool security hardening:
- Block LLM-provided auth headers for hosts with registered credentials
- Return structured authentication_required error for missing credentials
- Strip sensitive response headers (Set-Cookie, WWW-Authenticate, Authorization)
- Scan response body through LeakDetector before returning to LLM

Mission capability leases: registered mission_create/list/fire/pause/resume/delete
as a "missions" capability so threads receive leases. Removed routine_* aliases
from effect adapter — descriptions mention "routine" for LLM intent mapping.

Includes 10 integration tests (tests/skill_credential_injection.rs) covering
the full pipeline: YAML parsing → validation → registry → HttpTool wiring →
per-user isolation.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-27 19:33:58 -07:00
co-authored by Claude Opus 4.6
parent 429f6da7e0
commit 96266cb46d
15 changed files with 1747 additions and 15 deletions
+12 -2
View File
@@ -282,6 +282,7 @@ impl AppBuilder {
Option<Arc<dyn EmbeddingProvider>>,
Option<Arc<Workspace>>,
Option<Arc<dyn crate::tools::SoftwareBuilder>>,
Arc<SharedCredentialRegistry>,
),
anyhow::Error,
> {
@@ -425,7 +426,7 @@ impl AppBuilder {
None
};
Ok((safety, tools, embeddings, workspace, builder))
Ok((safety, tools, embeddings, workspace, builder, credential_registry))
}
/// Phase 5: Load WASM tools, MCP servers, and create extension manager.
@@ -782,7 +783,8 @@ impl AppBuilder {
} else {
self.init_llm().await?
};
let (safety, tools, embeddings, workspace, builder) = self.init_tools(&llm).await?;
let (safety, tools, embeddings, workspace, builder, credential_registry) =
self.init_tools(&llm).await?;
// Create hook registry early so runtime extension activation can register hooks.
let hooks = Arc::new(HookRegistry::new());
@@ -867,6 +869,14 @@ impl AppBuilder {
if !loaded.is_empty() {
tracing::debug!("Loaded {} skill(s): {}", loaded.len(), loaded.join(", "));
}
// Register credential mappings from skill frontmatter into the
// shared registry so the HTTP tool can auto-inject credentials.
crate::skills::register_skill_credentials(
registry.skills(),
&credential_registry,
);
let registry = Arc::new(std::sync::RwLock::new(registry));
let catalog = crate::skills::catalog::shared_catalog();
tools.register_skill_tools(Arc::clone(&registry), Arc::clone(&catalog));
+13 -8
View File
@@ -89,8 +89,7 @@ impl EffectBridgeAdapter {
let mgr = mgr.as_ref()?;
let result = match action_name {
// routine_create maps to mission_create in v2
"mission_create" | "routine_create" => {
"mission_create" => {
let name = params
.get("name")
.or_else(|| params.get("_args").and_then(|a| a.get(0)))
@@ -116,7 +115,7 @@ impl EffectBridgeAdapter {
Err(e) => Err(e),
}
}
"mission_list" | "routine_list" => match mgr.list_missions(context.project_id).await {
"mission_list" => match mgr.list_missions(context.project_id).await {
Ok(missions) => {
let list: Vec<serde_json::Value> = missions
.iter()
@@ -135,7 +134,7 @@ impl EffectBridgeAdapter {
}
Err(e) => Err(e),
},
"mission_fire" | "routine_fire" => {
"mission_fire" => {
let id_str = params
.get("id")
.or_else(|| params.get("_args").and_then(|a| a.get(0)))
@@ -159,8 +158,7 @@ impl EffectBridgeAdapter {
Err(e) => Err(e),
}
}
"mission_pause" | "mission_resume" | "routine_pause" | "routine_resume"
| "routine_update" => {
"mission_pause" | "mission_resume" => {
let id_str = params
.get("id")
.or_else(|| params.get("_args").and_then(|a| a.get(0)))
@@ -174,7 +172,7 @@ impl EffectBridgeAdapter {
match id {
Ok(id) => {
let res =
if action_name == "mission_pause" || action_name == "routine_pause" {
if action_name == "mission_pause" {
mgr.pause_mission(id).await
} else {
mgr.resume_mission(id).await
@@ -187,7 +185,7 @@ impl EffectBridgeAdapter {
Err(e) => Err(e),
}
}
"routine_delete" | "mission_delete" => {
"mission_delete" => {
let id_str = params
.get("id")
.or_else(|| params.get("name")) // routine_delete uses "name" param
@@ -516,6 +514,13 @@ fn is_v1_only_tool(name: &str) -> bool {
| "cancel-job"
| "build_software"
| "build-software"
| "routine_create"
| "routine_list"
| "routine_fire"
| "routine_pause"
| "routine_resume"
| "routine_update"
| "routine_delete"
)
}
+87
View File
@@ -139,6 +139,93 @@ pub async fn init_engine(agent: &Agent) -> Result<(), Error> {
});
}
// Register mission functions as a capability so threads receive leases.
// Handled by EffectBridgeAdapter::handle_mission_call() before the
// regular tool executor. Use "mission_*" names only — descriptions
// mention "routine" so the LLM maps user intent correctly.
capabilities.register(Capability {
name: "missions".into(),
description: "Mission and routine lifecycle management".into(),
actions: vec![
ironclaw_engine::ActionDef {
name: "mission_create".into(),
description: "Create a new mission (routine). Use when the user wants to set up a recurring task, scheduled check, or periodic routine.".into(),
parameters_schema: serde_json::json!({
"type": "object",
"properties": {
"name": {"type": "string", "description": "Short name for the mission/routine"},
"goal": {"type": "string", "description": "What this mission should accomplish each run"},
"cadence": {"type": "string", "description": "How often to run: 'hourly', '30m', '6h', 'daily', 'manual'"}
},
"required": ["name", "goal"]
}),
effects: vec![],
requires_approval: false,
},
ironclaw_engine::ActionDef {
name: "mission_list".into(),
description: "List all missions and routines in the current project.".into(),
parameters_schema: serde_json::json!({"type": "object"}),
effects: vec![],
requires_approval: false,
},
ironclaw_engine::ActionDef {
name: "mission_fire".into(),
description: "Manually trigger a mission or routine to run immediately.".into(),
parameters_schema: serde_json::json!({
"type": "object",
"properties": {
"id": {"type": "string", "description": "Mission/routine ID to trigger"}
},
"required": ["id"]
}),
effects: vec![],
requires_approval: false,
},
ironclaw_engine::ActionDef {
name: "mission_pause".into(),
description: "Pause a running mission or routine.".into(),
parameters_schema: serde_json::json!({
"type": "object",
"properties": {
"id": {"type": "string", "description": "Mission/routine ID to pause"}
},
"required": ["id"]
}),
effects: vec![],
requires_approval: false,
},
ironclaw_engine::ActionDef {
name: "mission_resume".into(),
description: "Resume a paused mission or routine.".into(),
parameters_schema: serde_json::json!({
"type": "object",
"properties": {
"id": {"type": "string", "description": "Mission/routine ID to resume"}
},
"required": ["id"]
}),
effects: vec![],
requires_approval: false,
},
ironclaw_engine::ActionDef {
name: "mission_delete".into(),
description: "Delete a mission or routine permanently.".into(),
parameters_schema: serde_json::json!({
"type": "object",
"properties": {
"id": {"type": "string", "description": "Mission/routine ID to delete"}
},
"required": ["id"]
}),
effects: vec![],
requires_approval: false,
},
],
knowledge: vec![],
policies: vec![],
});
let leases = Arc::new(LeaseManager::new());
let policy = Arc::new(PolicyEngine::new());
+2 -1
View File
@@ -118,7 +118,7 @@ fn v1_skill_to_memory_doc(skill: &LoadedSkill, project_id: ProjectId) -> MemoryD
#[cfg(test)]
mod tests {
use super::*;
use ironclaw_skills::types::{ActivationCriteria, SkillManifest};
use ironclaw_skills::types::{ActivationCriteria, SkillManifest, SkillTrust};
use std::path::PathBuf;
fn make_v1_skill(name: &str, content: &str) -> LoadedSkill {
@@ -131,6 +131,7 @@ mod tests {
keywords: vec!["test".to_string()],
..Default::default()
},
credentials: vec![],
metadata: None,
},
prompt_content: content.to_string(),
+1
View File
@@ -134,6 +134,7 @@ mod tests {
version: "1.0.0".to_string(),
description: String::new(),
activation: ActivationCriteria::default(),
credentials: vec![],
metadata: None,
},
prompt_content: "test".to_string(),
+221
View File
@@ -14,3 +14,224 @@ pub use ironclaw_skills::*;
// Re-export attenuation at the same path as before.
pub use attenuation::{AttenuationResult, attenuate_tools};
use crate::secrets::{CredentialLocation, CredentialMapping};
use ironclaw_skills::types::{SkillCredentialLocation, SkillCredentialSpec};
/// Convert a skill credential location to the main crate's [`CredentialLocation`].
fn convert_credential_location(loc: &SkillCredentialLocation) -> CredentialLocation {
match loc {
SkillCredentialLocation::Bearer => CredentialLocation::AuthorizationBearer,
SkillCredentialLocation::BasicAuth { username } => CredentialLocation::AuthorizationBasic {
username: username.clone(),
},
SkillCredentialLocation::Header { name, prefix } => CredentialLocation::Header {
name: name.clone(),
prefix: prefix.clone(),
},
SkillCredentialLocation::QueryParam { name } => CredentialLocation::QueryParam {
name: name.clone(),
},
}
}
/// Convert a [`SkillCredentialSpec`] to a [`CredentialMapping`] for the
/// [`SharedCredentialRegistry`](crate::tools::wasm::SharedCredentialRegistry).
pub fn credential_spec_to_mapping(spec: &SkillCredentialSpec) -> CredentialMapping {
CredentialMapping {
secret_name: spec.name.clone(),
location: convert_credential_location(&spec.location),
host_patterns: spec.hosts.clone(),
}
}
/// Register credential mappings from loaded skills into the shared registry.
///
/// Validates each spec before registration; invalid specs are logged and skipped.
pub fn register_skill_credentials(
skills: &[LoadedSkill],
registry: &crate::tools::wasm::SharedCredentialRegistry,
) {
let mut count = 0usize;
for skill in skills {
for spec in &skill.manifest.credentials {
let errors = ironclaw_skills::validation::validate_credential_spec(spec);
if !errors.is_empty() {
tracing::warn!(
skill = %skill.name(),
credential = %spec.name,
errors = ?errors,
"Skipping invalid credential spec"
);
continue;
}
let mapping = credential_spec_to_mapping(spec);
tracing::debug!(
skill = %skill.name(),
credential = %spec.name,
hosts = ?spec.hosts,
"Registering skill credential mapping"
);
registry.add_mappings(std::iter::once(mapping));
count += 1;
}
}
if count > 0 {
tracing::debug!(count, "Registered skill credential mappings");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_convert_bearer_location() {
let loc = ironclaw_skills::SkillCredentialLocation::Bearer;
let converted = convert_credential_location(&loc);
assert!(matches!(
converted,
crate::secrets::CredentialLocation::AuthorizationBearer
));
}
#[test]
fn test_convert_basic_auth_location() {
let loc = ironclaw_skills::SkillCredentialLocation::BasicAuth {
username: "admin".to_string(),
};
let converted = convert_credential_location(&loc);
match converted {
crate::secrets::CredentialLocation::AuthorizationBasic { username } => {
assert_eq!(username, "admin");
}
_ => panic!("expected AuthorizationBasic"),
}
}
#[test]
fn test_convert_header_location() {
let loc = ironclaw_skills::SkillCredentialLocation::Header {
name: "X-API-Key".to_string(),
prefix: Some("Token".to_string()),
};
let converted = convert_credential_location(&loc);
match converted {
crate::secrets::CredentialLocation::Header { name, prefix } => {
assert_eq!(name, "X-API-Key");
assert_eq!(prefix, Some("Token".to_string()));
}
_ => panic!("expected Header"),
}
}
#[test]
fn test_convert_query_param_location() {
let loc = ironclaw_skills::SkillCredentialLocation::QueryParam {
name: "key".to_string(),
};
let converted = convert_credential_location(&loc);
match converted {
crate::secrets::CredentialLocation::QueryParam { name } => {
assert_eq!(name, "key");
}
_ => panic!("expected QueryParam"),
}
}
#[test]
fn test_credential_spec_to_mapping() {
let spec = ironclaw_skills::SkillCredentialSpec {
name: "github_token".to_string(),
provider: "github".to_string(),
location: ironclaw_skills::SkillCredentialLocation::Bearer,
hosts: vec!["api.github.com".to_string(), "*.github.com".to_string()],
oauth: None,
setup_instructions: None,
};
let mapping = super::credential_spec_to_mapping(&spec);
assert_eq!(mapping.secret_name, "github_token");
assert!(matches!(
mapping.location,
crate::secrets::CredentialLocation::AuthorizationBearer
));
assert_eq!(mapping.host_patterns.len(), 2);
assert_eq!(mapping.host_patterns[0], "api.github.com");
}
#[test]
fn test_register_skill_credentials_valid() {
use ironclaw_skills::types::*;
use std::path::PathBuf;
let skill = ironclaw_skills::LoadedSkill {
manifest: SkillManifest {
name: "test-api".to_string(),
version: "1.0.0".to_string(),
description: "Test".to_string(),
activation: ActivationCriteria::default(),
credentials: vec![SkillCredentialSpec {
name: "test_token".to_string(),
provider: "test".to_string(),
location: SkillCredentialLocation::Bearer,
hosts: vec!["api.test.com".to_string()],
oauth: None,
setup_instructions: None,
}],
metadata: None,
},
prompt_content: "test".to_string(),
trust: SkillTrust::Trusted,
source: SkillSource::User(PathBuf::from("/tmp/test")),
content_hash: "sha256:000".to_string(),
compiled_patterns: vec![],
lowercased_keywords: vec![],
lowercased_exclude_keywords: vec![],
lowercased_tags: vec![],
};
let registry = crate::tools::wasm::SharedCredentialRegistry::new();
register_skill_credentials(&[skill], &registry);
assert!(registry.has_credentials_for_host("api.test.com"));
assert!(!registry.has_credentials_for_host("other.host.com"));
}
#[test]
fn test_register_skill_credentials_invalid_skipped() {
use ironclaw_skills::types::*;
use std::path::PathBuf;
let skill = ironclaw_skills::LoadedSkill {
manifest: SkillManifest {
name: "bad-skill".to_string(),
version: "1.0.0".to_string(),
description: "Test".to_string(),
activation: ActivationCriteria::default(),
credentials: vec![SkillCredentialSpec {
name: "INVALID_NAME".to_string(), // uppercase = invalid
provider: "test".to_string(),
location: SkillCredentialLocation::Bearer,
hosts: vec!["api.test.com".to_string()],
oauth: None,
setup_instructions: None,
}],
metadata: None,
},
prompt_content: "test".to_string(),
trust: SkillTrust::Trusted,
source: SkillSource::User(PathBuf::from("/tmp/test")),
content_hash: "sha256:000".to_string(),
compiled_patterns: vec![],
lowercased_keywords: vec![],
lowercased_exclude_keywords: vec![],
lowercased_tags: vec![],
};
let registry = crate::tools::wasm::SharedCredentialRegistry::new();
register_skill_credentials(&[skill], &registry);
// Invalid spec should be skipped — host should NOT be registered
assert!(!registry.has_credentials_for_host("api.test.com"));
}
}
+141 -1
View File
@@ -464,6 +464,31 @@ impl Tool for HttpTool {
// Parse headers
let mut headers_vec = parse_headers_param(params.get("headers"))?;
// Block LLM-provided authorization headers when the host has registered
// credential mappings. Credentials must come from the registry, not from
// LLM-generated arguments — prevents prompt-injection exfiltration.
if let Some(registry) = self.credential_registry.as_ref() {
let cred_host = parsed_url.host_str().unwrap_or("");
if registry.has_credentials_for_host(cred_host) {
let forbidden: &[&str] = &[
"authorization",
"x-api-key",
"api-key",
"x-auth-token",
];
for (name, _) in &headers_vec {
if forbidden.iter().any(|f| name.eq_ignore_ascii_case(f)) {
return Err(ToolError::NotAuthorized(format!(
"Manual '{}' header blocked for host '{}': \
credentials are auto-injected by the credential system",
name, cred_host
)));
}
}
}
}
let timeout_secs = parse_timeout_secs_param(params.get("timeout_secs"))?;
let save_to = parse_save_to_param(params.get("save_to"))?;
let effective_timeout = Duration::from_secs(timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));
@@ -541,6 +566,20 @@ impl Tool for HttpTool {
request = request.query(&[(name.as_str(), value.as_str())]);
}
}
Err(crate::secrets::SecretError::NotFound(_)) => {
return Err(ToolError::ExecutionFailed(
serde_json::json!({
"error": "authentication_required",
"credential_name": mapping.secret_name,
"message": format!(
"Credential '{}' is not configured. \
Use the auth_setup tool to set up credentials before making this request.",
mapping.secret_name
)
})
.to_string(),
));
}
Err(e) => {
tracing::warn!(
secret = %mapping.secret_name,
@@ -701,10 +740,33 @@ impl Tool for HttpTool {
let status = response.status().as_u16();
// Strip sensitive response headers before they reach the LLM context.
// These headers may contain tokens, session cookies, or auth challenges
// that the LLM should never see (Pica pattern: auth header stripping).
const REDACTED_RESPONSE_HEADERS: &[&str] = &[
"authorization",
"www-authenticate",
"set-cookie",
"x-api-key",
"x-auth-token",
"proxy-authenticate",
"proxy-authorization",
];
let headers: HashMap<String, String> = response
.headers()
.iter()
.filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string())))
.filter_map(|(k, v)| {
let key = k.to_string();
if REDACTED_RESPONSE_HEADERS
.iter()
.any(|r| key.eq_ignore_ascii_case(r))
{
None
} else {
v.to_str().ok().map(|v| (key, v.to_string()))
}
})
.collect();
// Use a larger size limit when saving to disk (file downloads)
@@ -777,6 +839,21 @@ impl Tool for HttpTool {
let body_text = String::from_utf8_lossy(&body_bytes).into_owned();
// Scan response body for leaked credentials before it reaches the LLM.
let response_detector = LeakDetector::new();
let scan_result = response_detector.scan(&body_text);
if scan_result.should_block {
tracing::warn!(
url = %parsed_url,
matches = scan_result.matches.len(),
"Response body contains leaked credential pattern, blocking"
);
return Err(ToolError::NotAuthorized(
"Response blocked: contains credential patterns that must not reach the LLM"
.to_string(),
));
}
// Record the HTTP exchange if interceptor is present (recording mode)
if let Some(ref interceptor) = ctx.http_interceptor {
let resp_headers: Vec<(String, String)> = headers
@@ -1483,4 +1560,67 @@ mod tests {
let err = validate_save_to_path("/tmp").unwrap_err();
assert!(err.to_string().contains("must be under /tmp/"));
}
// ── Forbidden auth header blocking tests ───────────────────────────
#[test]
fn test_forbidden_auth_header_blocked_for_registered_host() {
// parse_headers_param is called before the execute() block check,
// so we test the blocking logic directly by simulating what execute does.
use crate::secrets::CredentialMapping;
use crate::tools::wasm::SharedCredentialRegistry;
let registry = Arc::new(SharedCredentialRegistry::new());
registry.add_mappings(vec![CredentialMapping::bearer(
"github_token",
"api.github.com",
)]);
// Simulate: host has registered credentials, LLM provides Authorization header
let cred_host = "api.github.com";
assert!(registry.has_credentials_for_host(cred_host));
let forbidden: &[&str] = &["authorization", "x-api-key", "api-key", "x-auth-token"];
let llm_headers = [("Authorization".to_string(), "Bearer stolen_token".to_string())];
let blocked = llm_headers.iter().any(|(name, _)| {
forbidden.iter().any(|f| name.eq_ignore_ascii_case(f))
});
assert!(blocked, "LLM-provided Authorization header should be blocked");
}
#[test]
fn test_non_auth_header_allowed_for_registered_host() {
use crate::secrets::CredentialMapping;
use crate::tools::wasm::SharedCredentialRegistry;
let registry = Arc::new(SharedCredentialRegistry::new());
registry.add_mappings(vec![CredentialMapping::bearer(
"github_token",
"api.github.com",
)]);
let forbidden: &[&str] = &["authorization", "x-api-key", "api-key", "x-auth-token"];
let llm_headers = [
("Accept".to_string(), "application/json".to_string()),
("Content-Type".to_string(), "application/json".to_string()),
];
let blocked = llm_headers.iter().any(|(name, _)| {
forbidden.iter().any(|f| name.eq_ignore_ascii_case(f))
});
assert!(!blocked, "Non-auth headers should not be blocked");
}
#[test]
fn test_auth_header_allowed_for_unregistered_host() {
use crate::tools::wasm::SharedCredentialRegistry;
// Empty registry — no credential mappings registered
let registry = Arc::new(SharedCredentialRegistry::new());
// Host has NO registered credentials, so LLM-provided auth headers are fine
let cred_host = "api.example.com";
assert!(!registry.has_credentials_for_host(cred_host));
}
}