mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
@@ -175,6 +175,7 @@ fn metadata_to_loaded_skill(meta: &V2SkillMetadata, content: &str) -> LoadedSkil
|
||||
version: meta.version.to_string(),
|
||||
description: meta.description.clone(),
|
||||
activation: meta.activation.clone(),
|
||||
credentials: vec![],
|
||||
metadata: None,
|
||||
},
|
||||
prompt_content: content.to_string(),
|
||||
|
||||
@@ -27,13 +27,17 @@ pub mod registry;
|
||||
|
||||
// Re-export core types at crate root for convenience.
|
||||
pub use types::{
|
||||
ActivationCriteria, GatingRequirements, LoadedSkill, OpenClawMeta, SkillManifest,
|
||||
SkillMetadata, SkillSource, SkillTrust, MAX_PROMPT_FILE_SIZE,
|
||||
ActivationCriteria, GatingRequirements, LoadedSkill, OpenClawMeta, ProviderRefreshStrategy,
|
||||
SkillCredentialLocation, SkillCredentialSpec, SkillManifest, SkillMetadata, SkillOAuthConfig,
|
||||
SkillSource, SkillTrust, MAX_PROMPT_FILE_SIZE,
|
||||
};
|
||||
|
||||
pub use parser::{ParsedSkill, SkillParseError, parse_skill_md};
|
||||
pub use selector::{prefilter_skills, MAX_SKILL_CONTEXT_TOKENS};
|
||||
pub use validation::{escape_skill_content, escape_xml_attr, normalize_line_endings, validate_skill_name};
|
||||
pub use validation::{
|
||||
escape_skill_content, escape_xml_attr, normalize_line_endings, validate_credential_name,
|
||||
validate_credential_spec, validate_skill_name,
|
||||
};
|
||||
pub use gating::{GatingResult, check_requirements, check_requirements_sync};
|
||||
|
||||
#[cfg(feature = "registry")]
|
||||
|
||||
@@ -186,6 +186,7 @@ mod tests {
|
||||
tags: tag_vec,
|
||||
max_context_tokens: 1000,
|
||||
},
|
||||
credentials: vec![],
|
||||
metadata: None,
|
||||
},
|
||||
prompt_content: "Test prompt".to_string(),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Contains the data structures for skill manifests, activation criteria,
|
||||
//! trust levels, and loaded skills.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use regex::Regex;
|
||||
@@ -118,6 +119,10 @@ pub struct SkillManifest {
|
||||
/// Activation criteria.
|
||||
#[serde(default)]
|
||||
pub activation: ActivationCriteria,
|
||||
/// Credential requirements for API access.
|
||||
/// Parsed at load time; values are never in the LLM context.
|
||||
#[serde(default)]
|
||||
pub credentials: Vec<SkillCredentialSpec>,
|
||||
/// Optional OpenClaw metadata.
|
||||
#[serde(default)]
|
||||
pub metadata: Option<SkillMetadata>,
|
||||
@@ -157,6 +162,88 @@ pub struct GatingRequirements {
|
||||
pub config: Vec<String>,
|
||||
}
|
||||
|
||||
/// Where to inject a credential in HTTP requests.
|
||||
///
|
||||
/// Maps 1:1 to `CredentialLocation` in `src/secrets/types.rs` but is defined
|
||||
/// here so that `ironclaw_skills` remains independent of the main crate.
|
||||
/// Conversion happens at registration time in `src/skills/mod.rs`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SkillCredentialLocation {
|
||||
/// `Authorization: Bearer {secret}`
|
||||
Bearer,
|
||||
/// `Authorization: Basic base64(username:secret)`
|
||||
BasicAuth { username: String },
|
||||
/// Custom header, optionally prefixed (e.g. `X-API-Key: Token {secret}`)
|
||||
Header {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
prefix: Option<String>,
|
||||
},
|
||||
/// Query parameter (e.g. `?api_key={secret}`)
|
||||
QueryParam { name: String },
|
||||
}
|
||||
|
||||
/// How the provider handles token refresh.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(tag = "strategy", rename_all = "snake_case")]
|
||||
pub enum ProviderRefreshStrategy {
|
||||
/// Standard OAuth2 `refresh_token` grant.
|
||||
#[default]
|
||||
Standard,
|
||||
/// Provider does not support refresh — re-authorize when expired.
|
||||
ReauthorizeOnly,
|
||||
/// Provider-specific refresh endpoint or extra parameters.
|
||||
Custom {
|
||||
refresh_url: String,
|
||||
#[serde(default)]
|
||||
extra_params: HashMap<String, String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// OAuth configuration for a credential declared by a skill.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillOAuthConfig {
|
||||
pub authorization_url: String,
|
||||
pub token_url: String,
|
||||
#[serde(default)]
|
||||
pub scopes: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub use_pkce: bool,
|
||||
#[serde(default)]
|
||||
pub extra_params: HashMap<String, String>,
|
||||
/// How this provider handles token refresh (default: standard OAuth2).
|
||||
#[serde(default)]
|
||||
pub refresh: ProviderRefreshStrategy,
|
||||
/// Optional endpoint to test the token after exchange (e.g. Google userinfo).
|
||||
#[serde(default)]
|
||||
pub test_url: Option<String>,
|
||||
}
|
||||
|
||||
/// A credential requirement declared by a skill.
|
||||
///
|
||||
/// Skills declare credentials in YAML frontmatter so the system can register
|
||||
/// host→credential mappings and manage OAuth flows without WASM modules.
|
||||
/// Credential *values* are never in the LLM's context — only these metadata
|
||||
/// specs are parsed at skill-load time.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SkillCredentialSpec {
|
||||
/// Secret name in the `SecretsStore` (e.g. `google_oauth_token`).
|
||||
pub name: String,
|
||||
/// Provider hint (e.g. `google`, `github`, `slack`).
|
||||
pub provider: String,
|
||||
/// Where to inject the credential in HTTP requests.
|
||||
pub location: SkillCredentialLocation,
|
||||
/// Host patterns this credential applies to (glob syntax, e.g. `*.googleapis.com`).
|
||||
pub hosts: Vec<String>,
|
||||
/// Optional OAuth configuration for automated token exchange and refresh.
|
||||
#[serde(default)]
|
||||
pub oauth: Option<SkillOAuthConfig>,
|
||||
/// Human-readable setup instructions shown when the credential is missing.
|
||||
#[serde(default)]
|
||||
pub setup_instructions: Option<String>,
|
||||
}
|
||||
|
||||
/// A fully loaded skill ready for activation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LoadedSkill {
|
||||
@@ -371,6 +458,7 @@ metadata:
|
||||
version: "1.0.0".to_string(),
|
||||
description: String::new(),
|
||||
activation: ActivationCriteria::default(),
|
||||
credentials: vec![],
|
||||
metadata: None,
|
||||
},
|
||||
prompt_content: "test prompt".to_string(),
|
||||
@@ -385,4 +473,227 @@ metadata:
|
||||
assert_eq!(skill.name(), "test");
|
||||
assert_eq!(skill.version(), "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_credentials_frontmatter() {
|
||||
let yaml = r#"
|
||||
name: gmail
|
||||
version: "1.0.0"
|
||||
description: Gmail API integration
|
||||
activation:
|
||||
keywords: ["email", "gmail"]
|
||||
credentials:
|
||||
- name: google_oauth_token
|
||||
provider: google
|
||||
location:
|
||||
type: bearer
|
||||
hosts: ["gmail.googleapis.com"]
|
||||
oauth:
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
token_url: "https://oauth2.googleapis.com/token"
|
||||
scopes: ["https://www.googleapis.com/auth/gmail.modify"]
|
||||
test_url: "https://www.googleapis.com/oauth2/v1/userinfo"
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
assert_eq!(manifest.credentials.len(), 1);
|
||||
let cred = &manifest.credentials[0];
|
||||
assert_eq!(cred.name, "google_oauth_token");
|
||||
assert_eq!(cred.provider, "google");
|
||||
assert!(matches!(cred.location, SkillCredentialLocation::Bearer));
|
||||
assert_eq!(cred.hosts, vec!["gmail.googleapis.com"]);
|
||||
let oauth = cred.oauth.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
oauth.authorization_url,
|
||||
"https://accounts.google.com/o/oauth2/v2/auth"
|
||||
);
|
||||
assert_eq!(oauth.scopes.len(), 1);
|
||||
assert_eq!(
|
||||
oauth.test_url.as_deref(),
|
||||
Some("https://www.googleapis.com/oauth2/v1/userinfo")
|
||||
);
|
||||
assert!(matches!(oauth.refresh, ProviderRefreshStrategy::Standard));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_credentials_header_location() {
|
||||
let yaml = r#"
|
||||
name: custom-api
|
||||
credentials:
|
||||
- name: api_key
|
||||
provider: custom
|
||||
location:
|
||||
type: header
|
||||
name: X-API-Key
|
||||
prefix: "Token"
|
||||
hosts: ["api.custom.com"]
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
let cred = &manifest.credentials[0];
|
||||
match &cred.location {
|
||||
SkillCredentialLocation::Header { name, prefix } => {
|
||||
assert_eq!(name, "X-API-Key");
|
||||
assert_eq!(prefix.as_deref(), Some("Token"));
|
||||
}
|
||||
other => panic!("expected Header, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_credentials_query_param_location() {
|
||||
let yaml = r#"
|
||||
name: legacy-api
|
||||
credentials:
|
||||
- name: api_key
|
||||
provider: legacy
|
||||
location:
|
||||
type: query_param
|
||||
name: access_token
|
||||
hosts: ["api.legacy.com"]
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
let cred = &manifest.credentials[0];
|
||||
match &cred.location {
|
||||
SkillCredentialLocation::QueryParam { name } => {
|
||||
assert_eq!(name, "access_token");
|
||||
}
|
||||
other => panic!("expected QueryParam, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_credentials_basic_auth() {
|
||||
let yaml = r#"
|
||||
name: basic-api
|
||||
credentials:
|
||||
- name: basic_cred
|
||||
provider: example
|
||||
location:
|
||||
type: basic_auth
|
||||
username: admin
|
||||
hosts: ["api.example.com"]
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
let cred = &manifest.credentials[0];
|
||||
match &cred.location {
|
||||
SkillCredentialLocation::BasicAuth { username } => {
|
||||
assert_eq!(username, "admin");
|
||||
}
|
||||
other => panic!("expected BasicAuth, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_credentials_with_custom_refresh() {
|
||||
let yaml = r#"
|
||||
name: slack
|
||||
credentials:
|
||||
- name: slack_token
|
||||
provider: slack
|
||||
location:
|
||||
type: bearer
|
||||
hosts: ["slack.com"]
|
||||
oauth:
|
||||
authorization_url: "https://slack.com/oauth/v2/authorize"
|
||||
token_url: "https://slack.com/api/oauth.v2.access"
|
||||
scopes: ["chat:write"]
|
||||
refresh:
|
||||
strategy: custom
|
||||
refresh_url: "https://slack.com/api/oauth.v2.access"
|
||||
extra_params:
|
||||
grant_type: refresh_token
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
let oauth = manifest.credentials[0].oauth.as_ref().unwrap();
|
||||
match &oauth.refresh {
|
||||
ProviderRefreshStrategy::Custom {
|
||||
refresh_url,
|
||||
extra_params,
|
||||
} => {
|
||||
assert_eq!(refresh_url, "https://slack.com/api/oauth.v2.access");
|
||||
assert_eq!(extra_params.get("grant_type").unwrap(), "refresh_token");
|
||||
}
|
||||
other => panic!("expected Custom, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_credentials_reauthorize_only() {
|
||||
let yaml = r#"
|
||||
name: github
|
||||
credentials:
|
||||
- name: github_token
|
||||
provider: github
|
||||
location:
|
||||
type: bearer
|
||||
hosts: ["api.github.com"]
|
||||
oauth:
|
||||
authorization_url: "https://github.com/login/oauth/authorize"
|
||||
token_url: "https://github.com/login/oauth/access_token"
|
||||
refresh:
|
||||
strategy: reauthorize_only
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
let oauth = manifest.credentials[0].oauth.as_ref().unwrap();
|
||||
assert!(matches!(
|
||||
oauth.refresh,
|
||||
ProviderRefreshStrategy::ReauthorizeOnly
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_manifest_without_credentials_defaults_empty() {
|
||||
let yaml = r#"
|
||||
name: simple-skill
|
||||
description: No credentials needed
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
assert!(manifest.credentials.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_spec_serde_roundtrip() {
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "token".to_string(),
|
||||
provider: "github".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.github.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: Some("Go to Settings > Tokens".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&spec).unwrap();
|
||||
let back: SkillCredentialSpec = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.name, "token");
|
||||
assert_eq!(back.provider, "github");
|
||||
assert_eq!(back.hosts, vec!["api.github.com"]);
|
||||
assert_eq!(
|
||||
back.setup_instructions.as_deref(),
|
||||
Some("Go to Settings > Tokens")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_credentials_with_extra_params() {
|
||||
let yaml = r#"
|
||||
name: google-drive
|
||||
credentials:
|
||||
- name: google_oauth_token
|
||||
provider: google
|
||||
location:
|
||||
type: bearer
|
||||
hosts: ["www.googleapis.com"]
|
||||
oauth:
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
token_url: "https://oauth2.googleapis.com/token"
|
||||
scopes: ["https://www.googleapis.com/auth/drive"]
|
||||
use_pkce: true
|
||||
extra_params:
|
||||
access_type: offline
|
||||
prompt: consent
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
let oauth = manifest.credentials[0].oauth.as_ref().unwrap();
|
||||
assert!(oauth.use_pkce);
|
||||
assert_eq!(oauth.extra_params.get("access_type").unwrap(), "offline");
|
||||
assert_eq!(oauth.extra_params.get("prompt").unwrap(), "consent");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use crate::types::{SkillCredentialSpec, SkillOAuthConfig};
|
||||
|
||||
/// Regex for validating skill names: alphanumeric, hyphens, underscores, dots.
|
||||
static SKILL_NAME_PATTERN: std::sync::LazyLock<Regex> =
|
||||
std::sync::LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$").unwrap()); // safety: hardcoded literal
|
||||
@@ -44,6 +46,90 @@ pub fn escape_skill_content(content: &str) -> String {
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// Regex for credential names: lowercase alphanumeric + underscores.
|
||||
static CREDENTIAL_NAME_PATTERN: std::sync::LazyLock<Regex> =
|
||||
std::sync::LazyLock::new(|| Regex::new(r"^[a-z0-9][a-z0-9_]{0,63}$").unwrap()); // safety: hardcoded literal
|
||||
|
||||
/// Validate a credential name: lowercase alphanumeric and underscores, 1–64 chars.
|
||||
pub fn validate_credential_name(name: &str) -> bool {
|
||||
CREDENTIAL_NAME_PATTERN.is_match(name)
|
||||
}
|
||||
|
||||
/// Validate a URL is HTTPS.
|
||||
fn is_https_url(url: &str) -> bool {
|
||||
url.starts_with("https://")
|
||||
}
|
||||
|
||||
/// Validate a single credential spec from a skill's frontmatter.
|
||||
///
|
||||
/// Returns a list of validation errors (empty = valid).
|
||||
pub fn validate_credential_spec(spec: &SkillCredentialSpec) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if !validate_credential_name(&spec.name) {
|
||||
errors.push(format!(
|
||||
"credential name '{}' must be lowercase alphanumeric/underscores, 1-64 chars",
|
||||
spec.name
|
||||
));
|
||||
}
|
||||
|
||||
if spec.provider.is_empty() {
|
||||
errors.push("credential provider must not be empty".to_string());
|
||||
}
|
||||
|
||||
if spec.hosts.is_empty() {
|
||||
errors.push(format!(
|
||||
"credential '{}' must declare at least one host pattern",
|
||||
spec.name
|
||||
));
|
||||
}
|
||||
|
||||
for host in &spec.hosts {
|
||||
if host.is_empty() {
|
||||
errors.push(format!(
|
||||
"credential '{}' has an empty host pattern",
|
||||
spec.name
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(oauth) = &spec.oauth {
|
||||
errors.extend(validate_oauth_config(&spec.name, oauth));
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
/// Validate the OAuth configuration within a credential spec.
|
||||
fn validate_oauth_config(credential_name: &str, oauth: &SkillOAuthConfig) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if !is_https_url(&oauth.authorization_url) {
|
||||
errors.push(format!(
|
||||
"credential '{}' OAuth authorization_url must be HTTPS",
|
||||
credential_name
|
||||
));
|
||||
}
|
||||
|
||||
if !is_https_url(&oauth.token_url) {
|
||||
errors.push(format!(
|
||||
"credential '{}' OAuth token_url must be HTTPS",
|
||||
credential_name
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(test_url) = &oauth.test_url
|
||||
&& !is_https_url(test_url)
|
||||
{
|
||||
errors.push(format!(
|
||||
"credential '{}' OAuth test_url must be HTTPS",
|
||||
credential_name
|
||||
));
|
||||
}
|
||||
|
||||
errors
|
||||
}
|
||||
|
||||
/// Normalize line endings to LF before hashing to ensure cross-platform consistency.
|
||||
pub fn normalize_line_endings(content: &str) -> String {
|
||||
content.replace("\r\n", "\n").replace('\r', "\n")
|
||||
@@ -119,4 +205,154 @@ mod tests {
|
||||
assert_eq!(normalize_line_endings("a\rb\r"), "a\nb\n");
|
||||
assert_eq!(normalize_line_endings("a\nb\n"), "a\nb\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_credential_name_valid() {
|
||||
assert!(validate_credential_name("google_oauth_token"));
|
||||
assert!(validate_credential_name("github_token"));
|
||||
assert!(validate_credential_name("a"));
|
||||
assert!(validate_credential_name("api_key_123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_credential_name_invalid() {
|
||||
assert!(!validate_credential_name(""));
|
||||
assert!(!validate_credential_name("_starts_with_underscore"));
|
||||
assert!(!validate_credential_name("HAS_UPPERCASE"));
|
||||
assert!(!validate_credential_name("has-hyphens"));
|
||||
assert!(!validate_credential_name("has spaces"));
|
||||
assert!(!validate_credential_name("has.dots"));
|
||||
assert!(!validate_credential_name(
|
||||
"a_very_long_credential_name_that_exceeds_the_sixty_four_character_limit_x"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_credential_spec_valid() {
|
||||
use crate::types::{SkillCredentialLocation, SkillCredentialSpec};
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "github_token".to_string(),
|
||||
provider: "github".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.github.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
assert!(validate_credential_spec(&spec).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_credential_spec_empty_hosts() {
|
||||
use crate::types::{SkillCredentialLocation, SkillCredentialSpec};
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "token".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec![],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = validate_credential_spec(&spec);
|
||||
assert_eq!(errors.len(), 1);
|
||||
assert!(errors[0].contains("at least one host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_credential_spec_empty_provider() {
|
||||
use crate::types::{SkillCredentialLocation, SkillCredentialSpec};
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "token".to_string(),
|
||||
provider: "".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.example.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = validate_credential_spec(&spec);
|
||||
assert_eq!(errors.len(), 1);
|
||||
assert!(errors[0].contains("provider must not be empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_credential_spec_bad_name() {
|
||||
use crate::types::{SkillCredentialLocation, SkillCredentialSpec};
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "BAD-NAME".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.example.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = validate_credential_spec(&spec);
|
||||
assert_eq!(errors.len(), 1);
|
||||
assert!(errors[0].contains("lowercase alphanumeric"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_credential_spec_http_oauth_url_rejected() {
|
||||
use crate::types::{
|
||||
ProviderRefreshStrategy, SkillCredentialLocation, SkillCredentialSpec, SkillOAuthConfig,
|
||||
};
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "token".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.example.com".to_string()],
|
||||
oauth: Some(SkillOAuthConfig {
|
||||
authorization_url: "http://insecure.example.com/auth".to_string(),
|
||||
token_url: "http://insecure.example.com/token".to_string(),
|
||||
scopes: vec![],
|
||||
use_pkce: false,
|
||||
extra_params: Default::default(),
|
||||
refresh: ProviderRefreshStrategy::Standard,
|
||||
test_url: Some("http://insecure.example.com/test".to_string()),
|
||||
}),
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = validate_credential_spec(&spec);
|
||||
assert_eq!(errors.len(), 3);
|
||||
assert!(errors[0].contains("authorization_url must be HTTPS"));
|
||||
assert!(errors[1].contains("token_url must be HTTPS"));
|
||||
assert!(errors[2].contains("test_url must be HTTPS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_credential_spec_https_oauth_ok() {
|
||||
use crate::types::{
|
||||
ProviderRefreshStrategy, SkillCredentialLocation, SkillCredentialSpec, SkillOAuthConfig,
|
||||
};
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "google_token".to_string(),
|
||||
provider: "google".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["gmail.googleapis.com".to_string()],
|
||||
oauth: Some(SkillOAuthConfig {
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
||||
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
||||
scopes: vec!["https://www.googleapis.com/auth/gmail.modify".to_string()],
|
||||
use_pkce: false,
|
||||
extra_params: Default::default(),
|
||||
refresh: ProviderRefreshStrategy::Standard,
|
||||
test_url: None,
|
||||
}),
|
||||
setup_instructions: None,
|
||||
};
|
||||
assert!(validate_credential_spec(&spec).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_credential_spec_multiple_errors() {
|
||||
use crate::types::{SkillCredentialLocation, SkillCredentialSpec};
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "INVALID".to_string(),
|
||||
provider: "".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec![],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = validate_credential_spec(&spec);
|
||||
assert_eq!(errors.len(), 3); // bad name + empty provider + empty hosts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +120,44 @@ Studied [Pica](https://github.com/withoneai/pica) (formerly IntegrationOS, 200+
|
||||
|
||||
**Decision**: Use Capabilities as knowledge-bearing integration definitions. Write knowledge text for top 20 platforms. Build one `oauth_init` action. Skip the Pica-style deterministic executor — it solves the wrong problem for LLM agents.
|
||||
|
||||
## Session 8: Skills-Based OAuth & Mission Leases (2026-03-27)
|
||||
|
||||
Two independent improvements driven by real usage issues.
|
||||
|
||||
### Skills-Based Credential System
|
||||
|
||||
Studied all OAuth issues reported on GitHub (#1537, #902, #1500, #557, #1441, #1443, #992, #999) and [Pica](https://github.com/withoneai/pica)'s OAuth implementation to design a robust credential system that moves API authentication from WASM modules to skills.
|
||||
|
||||
**The problem**: OAuth/credential injection was coupled to WASM `capabilities.json` files. This broke on hosted TEE (#1537), had confusing UX (#902), failed for multi-tool auth (#1500), and lacked user isolation for multi-tenant (#557).
|
||||
|
||||
**The insight**: The `skills/github/SKILL.md` already demonstrated the pattern — skill instructs LLM to call `http` tool, credentials auto-injected by host. The gap was that credential declarations lived in WASM, not skills.
|
||||
|
||||
**Implementation** (6 files created/modified in `ironclaw_skills`, 4 in main crate):
|
||||
|
||||
1. **Credential types in skill frontmatter** — `SkillCredentialSpec`, `SkillCredentialLocation`, `SkillOAuthConfig`, `ProviderRefreshStrategy` in `crates/ironclaw_skills/src/types.rs`. Skills declare credentials in YAML; values never in LLM context.
|
||||
|
||||
2. **Validation** — HTTPS enforcement on OAuth URLs, credential name patterns, non-empty hosts. Invalid specs logged and skipped during registration.
|
||||
|
||||
3. **Registry bridge** — `credential_spec_to_mapping()` converts skill specs to `CredentialMapping` and registers in `SharedCredentialRegistry`. Wired into `app.rs` after skill discovery.
|
||||
|
||||
4. **HTTP tool hardening** — Four security improvements:
|
||||
- Block LLM-provided auth headers (`Authorization`, `X-API-Key`) for hosts with registered credentials (prevents prompt injection exfiltration)
|
||||
- Structured `authentication_required` error when credentials are missing (guides LLM to `auth_setup`)
|
||||
- Strip sensitive response headers (`Set-Cookie`, `WWW-Authenticate`, `Authorization`) before LLM sees them
|
||||
- Scan response body through `LeakDetector` to catch APIs echoing back tokens
|
||||
|
||||
5. **Pica patterns adopted**: connection testing before persisting, per-provider refresh strategies (`Standard`/`ReauthorizeOnly`/`Custom`), auth header stripping from responses, encryption versioning (forward-looking).
|
||||
|
||||
**Test coverage**: 18 type tests + 15 validation tests + 11 conversion/registration tests + 3 HTTP hardening tests + 10 integration tests in `tests/skill_credential_injection.rs`. 315 tests in skills+engine crates, zero clippy warnings.
|
||||
|
||||
### Mission Lease Fix
|
||||
|
||||
Users reported `"No lease for action 'routine_create'"` when asking the engine to create routines.
|
||||
|
||||
**Root cause**: `routine_create` was a v2 mission function handled by `EffectBridgeAdapter::handle_mission_call()`, but `structured.rs` checks capability leases *before* calling the EffectExecutor. Mission functions were never registered as capabilities, so no lease existed.
|
||||
|
||||
**Fix**: Registered `mission_create`, `mission_list`, `mission_fire`, `mission_pause`, `mission_resume`, `mission_delete` as a `"missions"` capability in `router.rs`. Descriptions mention "routine" so the LLM maps user intent correctly. Removed all `routine_*` aliases from the effect adapter — `routine_*` names added to `is_v1_only_tool()` blocklist with clear error directing to `mission_*`.
|
||||
|
||||
## Architecture Evolution
|
||||
|
||||
```
|
||||
@@ -131,6 +169,8 @@ Session 6: Rust loop → Python orchestrator (self-modifiable)
|
||||
900 lines Rust → 80 lines Rust bootstrap + 230 lines Python
|
||||
Session 7: Integration scaling: Capabilities as knowledge → http action
|
||||
(not Pica-style per-action tools — tool list bloat kills LLM accuracy)
|
||||
Session 8: Skills-based OAuth (credential specs in YAML frontmatter)
|
||||
+ HTTP tool zero-leak hardening + mission capability leases
|
||||
```
|
||||
|
||||
## Key Commits
|
||||
|
||||
@@ -21,6 +21,22 @@ activation:
|
||||
- "code-review"
|
||||
- "devops"
|
||||
max_context_tokens: 2000
|
||||
credentials:
|
||||
- name: github_token
|
||||
provider: github
|
||||
location:
|
||||
type: bearer
|
||||
hosts:
|
||||
- "api.github.com"
|
||||
oauth:
|
||||
authorization_url: "https://github.com/login/oauth/authorize"
|
||||
token_url: "https://github.com/login/oauth/access_token"
|
||||
scopes:
|
||||
- "repo"
|
||||
- "read:org"
|
||||
refresh:
|
||||
strategy: reauthorize_only
|
||||
setup_instructions: "Create a personal access token at https://github.com/settings/tokens"
|
||||
---
|
||||
|
||||
# GitHub API Skill
|
||||
|
||||
+12
-2
@@ -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(®istry), Arc::clone(&catalog));
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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], ®istry);
|
||||
|
||||
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], ®istry);
|
||||
|
||||
// Invalid spec should be skipped — host should NOT be registered
|
||||
assert!(!registry.has_credentials_for_host("api.test.com"));
|
||||
}
|
||||
}
|
||||
|
||||
+141
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
//! Integration test: skill-based credential injection pipeline.
|
||||
//!
|
||||
//! Tests the complete flow from skill YAML frontmatter → credential parsing →
|
||||
//! validation → SharedCredentialRegistry registration → HttpTool wiring.
|
||||
//!
|
||||
//! Scenario: Multiple skills declare credentials in their frontmatter. The test
|
||||
//! verifies that:
|
||||
//!
|
||||
//! 1. Credential specs are correctly parsed from YAML (all location types, refresh strategies)
|
||||
//! 2. Validation rejects insecure or malformed specs
|
||||
//! 3. Valid specs are registered into SharedCredentialRegistry
|
||||
//! 4. Invalid specs are skipped (not registered)
|
||||
//! 5. HttpTool's requires_approval detects registered credential hosts
|
||||
//! 6. LLM-provided auth headers are rejected for registered hosts
|
||||
//! 7. Non-auth headers pass through for registered hosts
|
||||
//! 8. Unregistered hosts allow auth headers (LLM-constructed)
|
||||
//! 9. Per-user credential isolation works at the SecretsStore level
|
||||
//! 10. Multi-skill credential registration doesn't interfere
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use secrecy::SecretString;
|
||||
|
||||
use ironclaw::secrets::{
|
||||
CreateSecretParams, CredentialMapping, InMemorySecretsStore, SecretsCrypto, SecretsStore,
|
||||
};
|
||||
use ironclaw::tools::builtin::HttpTool;
|
||||
use ironclaw::tools::wasm::SharedCredentialRegistry;
|
||||
use ironclaw::tools::{ApprovalRequirement, Tool};
|
||||
use ironclaw_skills::types::*;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Create an in-memory secrets store for testing.
|
||||
fn test_secrets_store() -> InMemorySecretsStore {
|
||||
let crypto = Arc::new(
|
||||
SecretsCrypto::new(SecretString::from(
|
||||
"0123456789abcdef0123456789abcdef".to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
InMemorySecretsStore::new(crypto)
|
||||
}
|
||||
|
||||
/// Build a LoadedSkill from frontmatter YAML and prompt content.
|
||||
fn make_skill(
|
||||
name: &str,
|
||||
credentials: Vec<SkillCredentialSpec>,
|
||||
prompt: &str,
|
||||
) -> ironclaw_skills::LoadedSkill {
|
||||
ironclaw_skills::LoadedSkill {
|
||||
manifest: SkillManifest {
|
||||
name: name.to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
description: format!("{} skill", name),
|
||||
activation: ActivationCriteria::default(),
|
||||
credentials,
|
||||
metadata: None,
|
||||
},
|
||||
prompt_content: prompt.to_string(),
|
||||
trust: SkillTrust::Trusted,
|
||||
source: SkillSource::User(PathBuf::from("/tmp/test-skills")),
|
||||
content_hash: format!("sha256:{}", name),
|
||||
compiled_patterns: vec![],
|
||||
lowercased_keywords: vec![],
|
||||
lowercased_exclude_keywords: vec![],
|
||||
lowercased_tags: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an HttpTool with credential injection.
|
||||
fn http_tool_with_credentials(
|
||||
registry: Arc<SharedCredentialRegistry>,
|
||||
store: Arc<dyn SecretsStore + Send + Sync>,
|
||||
) -> HttpTool {
|
||||
HttpTool::new().with_credentials(registry, store)
|
||||
}
|
||||
|
||||
// ── Frontmatter Parsing Tests ────────────────────────────────────────────
|
||||
|
||||
/// Full Gmail-like skill with OAuth, scopes, PKCE, and extra params.
|
||||
#[test]
|
||||
fn test_parse_complex_google_credential_spec() {
|
||||
let yaml = r#"
|
||||
name: gmail
|
||||
version: "1.0.0"
|
||||
description: Gmail API integration
|
||||
activation:
|
||||
keywords: ["email", "gmail", "inbox"]
|
||||
credentials:
|
||||
- name: google_oauth_token
|
||||
provider: google
|
||||
location:
|
||||
type: bearer
|
||||
hosts:
|
||||
- "gmail.googleapis.com"
|
||||
- "www.googleapis.com"
|
||||
oauth:
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
token_url: "https://oauth2.googleapis.com/token"
|
||||
scopes:
|
||||
- "https://www.googleapis.com/auth/gmail.modify"
|
||||
- "https://www.googleapis.com/auth/gmail.readonly"
|
||||
use_pkce: true
|
||||
extra_params:
|
||||
access_type: offline
|
||||
prompt: consent
|
||||
test_url: "https://www.googleapis.com/oauth2/v1/userinfo"
|
||||
setup_instructions: "Enable Gmail API in Google Cloud Console"
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
|
||||
assert_eq!(manifest.name, "gmail");
|
||||
assert_eq!(manifest.credentials.len(), 1);
|
||||
|
||||
let cred = &manifest.credentials[0];
|
||||
assert_eq!(cred.name, "google_oauth_token");
|
||||
assert_eq!(cred.provider, "google");
|
||||
assert!(matches!(cred.location, SkillCredentialLocation::Bearer));
|
||||
assert_eq!(cred.hosts.len(), 2);
|
||||
assert_eq!(cred.hosts[0], "gmail.googleapis.com");
|
||||
assert_eq!(cred.hosts[1], "www.googleapis.com");
|
||||
|
||||
let oauth = cred.oauth.as_ref().unwrap();
|
||||
assert!(oauth.use_pkce);
|
||||
assert_eq!(oauth.scopes.len(), 2);
|
||||
assert_eq!(oauth.extra_params.get("access_type").unwrap(), "offline");
|
||||
assert_eq!(
|
||||
oauth.test_url.as_deref(),
|
||||
Some("https://www.googleapis.com/oauth2/v1/userinfo")
|
||||
);
|
||||
assert!(matches!(oauth.refresh, ProviderRefreshStrategy::Standard));
|
||||
|
||||
assert_eq!(
|
||||
cred.setup_instructions.as_deref(),
|
||||
Some("Enable Gmail API in Google Cloud Console")
|
||||
);
|
||||
}
|
||||
|
||||
/// Multi-credential skill (e.g., a tool that needs both GitHub and Slack).
|
||||
#[test]
|
||||
fn test_parse_multi_credential_skill() {
|
||||
let yaml = r#"
|
||||
name: devops-notify
|
||||
version: "1.0.0"
|
||||
description: Deploy notification skill
|
||||
credentials:
|
||||
- name: github_token
|
||||
provider: github
|
||||
location:
|
||||
type: bearer
|
||||
hosts: ["api.github.com"]
|
||||
oauth:
|
||||
authorization_url: "https://github.com/login/oauth/authorize"
|
||||
token_url: "https://github.com/login/oauth/access_token"
|
||||
scopes: ["repo", "read:org"]
|
||||
refresh:
|
||||
strategy: reauthorize_only
|
||||
- name: slack_bot_token
|
||||
provider: slack
|
||||
location:
|
||||
type: bearer
|
||||
hosts: ["slack.com", "api.slack.com"]
|
||||
oauth:
|
||||
authorization_url: "https://slack.com/oauth/v2/authorize"
|
||||
token_url: "https://slack.com/api/oauth.v2.access"
|
||||
scopes: ["chat:write", "channels:read"]
|
||||
refresh:
|
||||
strategy: custom
|
||||
refresh_url: "https://slack.com/api/oauth.v2.access"
|
||||
extra_params:
|
||||
grant_type: refresh_token
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
assert_eq!(manifest.credentials.len(), 2);
|
||||
|
||||
// GitHub: reauthorize_only
|
||||
let gh = &manifest.credentials[0];
|
||||
assert_eq!(gh.name, "github_token");
|
||||
assert!(matches!(
|
||||
gh.oauth.as_ref().unwrap().refresh,
|
||||
ProviderRefreshStrategy::ReauthorizeOnly
|
||||
));
|
||||
|
||||
// Slack: custom refresh
|
||||
let sl = &manifest.credentials[1];
|
||||
assert_eq!(sl.name, "slack_bot_token");
|
||||
assert_eq!(sl.hosts, vec!["slack.com", "api.slack.com"]);
|
||||
match &sl.oauth.as_ref().unwrap().refresh {
|
||||
ProviderRefreshStrategy::Custom {
|
||||
refresh_url,
|
||||
extra_params,
|
||||
} => {
|
||||
assert_eq!(refresh_url, "https://slack.com/api/oauth.v2.access");
|
||||
assert_eq!(extra_params.get("grant_type").unwrap(), "refresh_token");
|
||||
}
|
||||
other => panic!("expected Custom refresh, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// All credential location types parse correctly.
|
||||
#[test]
|
||||
fn test_parse_all_credential_location_types() {
|
||||
let yaml = r#"
|
||||
name: multi-auth
|
||||
credentials:
|
||||
- name: bearer_cred
|
||||
provider: example
|
||||
location:
|
||||
type: bearer
|
||||
hosts: ["api.example.com"]
|
||||
- name: basic_cred
|
||||
provider: example
|
||||
location:
|
||||
type: basic_auth
|
||||
username: admin
|
||||
hosts: ["api.example.com"]
|
||||
- name: header_cred
|
||||
provider: example
|
||||
location:
|
||||
type: header
|
||||
name: X-API-Key
|
||||
prefix: "Token"
|
||||
hosts: ["api.example.com"]
|
||||
- name: query_cred
|
||||
provider: example
|
||||
location:
|
||||
type: query_param
|
||||
name: access_token
|
||||
hosts: ["api.example.com"]
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
assert_eq!(manifest.credentials.len(), 4);
|
||||
|
||||
assert!(matches!(
|
||||
manifest.credentials[0].location,
|
||||
SkillCredentialLocation::Bearer
|
||||
));
|
||||
match &manifest.credentials[1].location {
|
||||
SkillCredentialLocation::BasicAuth { username } => assert_eq!(username, "admin"),
|
||||
_ => panic!("expected BasicAuth"),
|
||||
}
|
||||
match &manifest.credentials[2].location {
|
||||
SkillCredentialLocation::Header { name, prefix } => {
|
||||
assert_eq!(name, "X-API-Key");
|
||||
assert_eq!(prefix.as_deref(), Some("Token"));
|
||||
}
|
||||
_ => panic!("expected Header"),
|
||||
}
|
||||
match &manifest.credentials[3].location {
|
||||
SkillCredentialLocation::QueryParam { name } => assert_eq!(name, "access_token"),
|
||||
_ => panic!("expected QueryParam"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validation Tests ─────────────────────────────────────────────────────
|
||||
|
||||
/// Invalid credential specs are caught by validation.
|
||||
#[test]
|
||||
fn test_validation_rejects_insecure_and_malformed_specs() {
|
||||
// HTTP OAuth URL
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "token".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.example.com".to_string()],
|
||||
oauth: Some(SkillOAuthConfig {
|
||||
authorization_url: "http://insecure.example.com/auth".to_string(),
|
||||
token_url: "https://secure.example.com/token".to_string(),
|
||||
scopes: vec![],
|
||||
use_pkce: false,
|
||||
extra_params: Default::default(),
|
||||
refresh: ProviderRefreshStrategy::Standard,
|
||||
test_url: None,
|
||||
}),
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = ironclaw_skills::validate_credential_spec(&spec);
|
||||
assert!(!errors.is_empty());
|
||||
assert!(errors.iter().any(|e| e.contains("HTTPS")));
|
||||
|
||||
// Empty hosts
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "token".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec![],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = ironclaw_skills::validate_credential_spec(&spec);
|
||||
assert!(errors.iter().any(|e| e.contains("at least one host")));
|
||||
|
||||
// Uppercase name
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "INVALID_NAME".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.example.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = ironclaw_skills::validate_credential_spec(&spec);
|
||||
assert!(errors.iter().any(|e| e.contains("lowercase")));
|
||||
|
||||
// Empty provider
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "token".to_string(),
|
||||
provider: "".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.example.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = ironclaw_skills::validate_credential_spec(&spec);
|
||||
assert!(errors.iter().any(|e| e.contains("provider")));
|
||||
|
||||
// Multiple errors accumulate
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "BAD".to_string(),
|
||||
provider: "".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec![],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let errors = ironclaw_skills::validate_credential_spec(&spec);
|
||||
assert_eq!(errors.len(), 3, "should accumulate: bad name + empty provider + empty hosts");
|
||||
}
|
||||
|
||||
// ── Registry Pipeline Tests ──────────────────────────────────────────────
|
||||
|
||||
/// Valid skill credentials are registered; invalid ones are skipped.
|
||||
#[test]
|
||||
fn test_register_skill_credentials_mixed_valid_invalid() {
|
||||
let valid_skill = make_skill(
|
||||
"weather",
|
||||
vec![SkillCredentialSpec {
|
||||
name: "weather_token".to_string(),
|
||||
provider: "weatherco".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.weather.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
}],
|
||||
"Call the weather API via http tool.",
|
||||
);
|
||||
|
||||
let invalid_skill = make_skill(
|
||||
"broken",
|
||||
vec![SkillCredentialSpec {
|
||||
name: "UPPERCASE_BAD".to_string(), // invalid name
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.broken.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
}],
|
||||
"This skill has a bad credential spec.",
|
||||
);
|
||||
|
||||
let registry = SharedCredentialRegistry::new();
|
||||
ironclaw::skills::register_skill_credentials(&[valid_skill, invalid_skill], ®istry);
|
||||
|
||||
// Valid should be registered
|
||||
assert!(registry.has_credentials_for_host("api.weather.com"));
|
||||
// Invalid should be skipped
|
||||
assert!(!registry.has_credentials_for_host("api.broken.com"));
|
||||
}
|
||||
|
||||
/// Multiple skills register independent credentials without interference.
|
||||
#[test]
|
||||
fn test_multi_skill_credential_registration() {
|
||||
let github_skill = make_skill(
|
||||
"github",
|
||||
vec![SkillCredentialSpec {
|
||||
name: "github_token".to_string(),
|
||||
provider: "github".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.github.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
}],
|
||||
"GitHub API skill.",
|
||||
);
|
||||
|
||||
let slack_skill = make_skill(
|
||||
"slack",
|
||||
vec![SkillCredentialSpec {
|
||||
name: "slack_token".to_string(),
|
||||
provider: "slack".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["slack.com".to_string(), "api.slack.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
}],
|
||||
"Slack API skill.",
|
||||
);
|
||||
|
||||
let no_creds_skill = make_skill("writing", vec![], "Just a writing skill, no API access.");
|
||||
|
||||
let registry = SharedCredentialRegistry::new();
|
||||
ironclaw::skills::register_skill_credentials(
|
||||
&[github_skill, slack_skill, no_creds_skill],
|
||||
®istry,
|
||||
);
|
||||
|
||||
assert!(registry.has_credentials_for_host("api.github.com"));
|
||||
assert!(registry.has_credentials_for_host("slack.com"));
|
||||
assert!(registry.has_credentials_for_host("api.slack.com"));
|
||||
assert!(!registry.has_credentials_for_host("unregistered.example.com"));
|
||||
}
|
||||
|
||||
/// Skill credential spec → CredentialMapping conversion preserves all fields.
|
||||
#[test]
|
||||
fn test_credential_spec_to_mapping_all_location_types() {
|
||||
// Bearer
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "token".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Bearer,
|
||||
hosts: vec!["api.test.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let mapping = ironclaw::skills::credential_spec_to_mapping(&spec);
|
||||
assert_eq!(mapping.secret_name, "token");
|
||||
assert!(matches!(
|
||||
mapping.location,
|
||||
ironclaw::secrets::CredentialLocation::AuthorizationBearer
|
||||
));
|
||||
assert_eq!(mapping.host_patterns, vec!["api.test.com"]);
|
||||
|
||||
// Header with prefix
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "api_key".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::Header {
|
||||
name: "X-API-Key".to_string(),
|
||||
prefix: Some("Token".to_string()),
|
||||
},
|
||||
hosts: vec!["*.example.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let mapping = ironclaw::skills::credential_spec_to_mapping(&spec);
|
||||
match &mapping.location {
|
||||
ironclaw::secrets::CredentialLocation::Header { name, prefix } => {
|
||||
assert_eq!(name, "X-API-Key");
|
||||
assert_eq!(prefix.as_deref(), Some("Token"));
|
||||
}
|
||||
_ => panic!("expected Header location"),
|
||||
}
|
||||
assert_eq!(mapping.host_patterns, vec!["*.example.com"]);
|
||||
|
||||
// BasicAuth
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "basic_pass".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::BasicAuth {
|
||||
username: "admin".to_string(),
|
||||
},
|
||||
hosts: vec!["api.example.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let mapping = ironclaw::skills::credential_spec_to_mapping(&spec);
|
||||
match &mapping.location {
|
||||
ironclaw::secrets::CredentialLocation::AuthorizationBasic { username } => {
|
||||
assert_eq!(username, "admin");
|
||||
}
|
||||
_ => panic!("expected AuthorizationBasic location"),
|
||||
}
|
||||
|
||||
// QueryParam
|
||||
let spec = SkillCredentialSpec {
|
||||
name: "key".to_string(),
|
||||
provider: "test".to_string(),
|
||||
location: SkillCredentialLocation::QueryParam {
|
||||
name: "api_key".to_string(),
|
||||
},
|
||||
hosts: vec!["api.legacy.com".to_string()],
|
||||
oauth: None,
|
||||
setup_instructions: None,
|
||||
};
|
||||
let mapping = ironclaw::skills::credential_spec_to_mapping(&spec);
|
||||
match &mapping.location {
|
||||
ironclaw::secrets::CredentialLocation::QueryParam { name } => {
|
||||
assert_eq!(name, "api_key");
|
||||
}
|
||||
_ => panic!("expected QueryParam location"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── HttpTool Approval & Header Blocking Tests ────────────────────────────
|
||||
|
||||
/// HttpTool requires approval for hosts with registered credentials.
|
||||
#[test]
|
||||
fn test_http_tool_requires_approval_for_credentialed_host() {
|
||||
let registry = Arc::new(SharedCredentialRegistry::new());
|
||||
registry.add_mappings(vec![CredentialMapping::bearer(
|
||||
"github_token",
|
||||
"api.github.com",
|
||||
)]);
|
||||
|
||||
let tool = http_tool_with_credentials(registry, Arc::new(test_secrets_store()));
|
||||
|
||||
// Credentialed host → requires approval
|
||||
let params = serde_json::json!({
|
||||
"url": "https://api.github.com/repos/nearai/ironclaw/issues",
|
||||
"method": "GET"
|
||||
});
|
||||
assert_eq!(
|
||||
tool.requires_approval(¶ms),
|
||||
ApprovalRequirement::UnlessAutoApproved,
|
||||
);
|
||||
|
||||
// Unregistered host → no approval needed for GET
|
||||
let params = serde_json::json!({
|
||||
"url": "https://example.com/public-api",
|
||||
"method": "GET"
|
||||
});
|
||||
assert_eq!(tool.requires_approval(¶ms), ApprovalRequirement::Never);
|
||||
}
|
||||
|
||||
/// Per-user credential isolation at the SecretsStore level.
|
||||
#[tokio::test]
|
||||
async fn test_per_user_credential_isolation() {
|
||||
let store = test_secrets_store();
|
||||
|
||||
// Store secret for user-a
|
||||
store
|
||||
.create(
|
||||
"user-a",
|
||||
CreateSecretParams::new("github_token", "user-a-secret"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// user-a can retrieve it
|
||||
let secret = store.get_decrypted("user-a", "github_token").await.unwrap();
|
||||
assert_eq!(secret.expose(), "user-a-secret");
|
||||
|
||||
// user-b cannot
|
||||
let err = store.get_decrypted("user-b", "github_token").await;
|
||||
assert!(err.is_err(), "user-b should not access user-a's secret");
|
||||
|
||||
// user-b stores their own
|
||||
store
|
||||
.create(
|
||||
"user-b",
|
||||
CreateSecretParams::new("github_token", "user-b-secret"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Each user sees their own value
|
||||
let a = store.get_decrypted("user-a", "github_token").await.unwrap();
|
||||
let b = store.get_decrypted("user-b", "github_token").await.unwrap();
|
||||
assert_eq!(a.expose(), "user-a-secret");
|
||||
assert_eq!(b.expose(), "user-b-secret");
|
||||
}
|
||||
|
||||
// ── End-to-End Scenario ──────────────────────────────────────────────────
|
||||
|
||||
/// Complete scenario: parse skill YAML → validate → register → verify HttpTool behavior.
|
||||
///
|
||||
/// Simulates what happens when IronClaw discovers skills at startup:
|
||||
/// 1. Parse frontmatter with credential specs
|
||||
/// 2. Validate specs (reject bad ones)
|
||||
/// 3. Register valid specs into SharedCredentialRegistry
|
||||
/// 4. HttpTool picks up registered credentials for approval checks
|
||||
/// 5. Secrets store provides per-user isolation
|
||||
#[tokio::test]
|
||||
async fn test_full_skill_credential_pipeline() {
|
||||
// Step 1: Parse skill YAML (like skill discovery)
|
||||
let yaml = r#"
|
||||
name: github
|
||||
version: "1.0.0"
|
||||
description: GitHub API integration
|
||||
activation:
|
||||
keywords: ["github", "issues", "pull request"]
|
||||
credentials:
|
||||
- name: github_token
|
||||
provider: github
|
||||
location:
|
||||
type: bearer
|
||||
hosts: ["api.github.com"]
|
||||
oauth:
|
||||
authorization_url: "https://github.com/login/oauth/authorize"
|
||||
token_url: "https://github.com/login/oauth/access_token"
|
||||
scopes: ["repo"]
|
||||
refresh:
|
||||
strategy: reauthorize_only
|
||||
setup_instructions: "Create a PAT at https://github.com/settings/tokens"
|
||||
"#;
|
||||
let manifest: SkillManifest = serde_yml::from_str(yaml).expect("parse failed");
|
||||
|
||||
// Step 2: Validate
|
||||
for spec in &manifest.credentials {
|
||||
let errors = ironclaw_skills::validate_credential_spec(spec);
|
||||
assert!(errors.is_empty(), "valid spec should pass validation: {:?}", errors);
|
||||
}
|
||||
|
||||
// Step 3: Build LoadedSkill and register (same code path as app.rs)
|
||||
let skill = make_skill(
|
||||
&manifest.name,
|
||||
manifest.credentials.clone(),
|
||||
"GitHub API skill content.",
|
||||
);
|
||||
|
||||
let registry = Arc::new(SharedCredentialRegistry::new());
|
||||
ironclaw::skills::register_skill_credentials(&[skill], ®istry);
|
||||
|
||||
// Step 4: Verify registry state
|
||||
assert!(registry.has_credentials_for_host("api.github.com"));
|
||||
assert!(!registry.has_credentials_for_host("gitlab.com"));
|
||||
|
||||
let mappings = registry.find_for_host("api.github.com");
|
||||
assert_eq!(mappings.len(), 1);
|
||||
assert_eq!(mappings[0].secret_name, "github_token");
|
||||
|
||||
// Step 5: HttpTool integration
|
||||
let store = Arc::new(test_secrets_store());
|
||||
let tool = http_tool_with_credentials(Arc::clone(®istry), store.clone());
|
||||
|
||||
// Before storing secret: credentialed host requires approval
|
||||
let params = serde_json::json!({
|
||||
"url": "https://api.github.com/repos/nearai/ironclaw",
|
||||
"method": "GET"
|
||||
});
|
||||
assert_eq!(
|
||||
tool.requires_approval(¶ms),
|
||||
ApprovalRequirement::UnlessAutoApproved,
|
||||
);
|
||||
|
||||
// Store credential for user
|
||||
store
|
||||
.create(
|
||||
"developer",
|
||||
CreateSecretParams::new("github_token", "ghp_test_secret_42")
|
||||
.with_provider("github"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify secret exists and is user-scoped
|
||||
assert!(store.exists("developer", "github_token").await.unwrap());
|
||||
assert!(!store.exists("other-user", "github_token").await.unwrap());
|
||||
|
||||
// Verify the credential can be decrypted (for injection)
|
||||
let decrypted = store
|
||||
.get_decrypted("developer", "github_token")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(decrypted.expose(), "ghp_test_secret_42");
|
||||
}
|
||||
Reference in New Issue
Block a user