Files
optimclaw/tests/skill_credential_injection.rs
T
[email protected]andClaude Opus 4.6 4d643f47c7 refactor: remove glob re-exports, fix clippy warnings, clean up duplicates
- Remove `pub use ironclaw_safety::*` from src/safety/mod.rs and migrate
  all 20+ call sites to import directly from `ironclaw_safety`
- Remove `pub use ironclaw_skills::*` from src/skills/mod.rs and migrate
  all 15+ call sites to import directly from `ironclaw_skills`
- Fix 4 clippy warnings: 2 shadow imports, 2 collapsible if-let chains
- Add missing SkillActivated arm to WASM channel StatusUpdate match
- Remove duplicate AuthRequired/AuthCompleted arms in repl.rs
- Update CLAUDE.md extracted crates guidance and prompt template rule
- Fix bench imports (safety_check, safety_pipeline)

46 files changed, zero warnings, 3836 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-27 23:51:24 -07:00

666 lines
23 KiB
Rust

//! 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], &registry);
// 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],
&registry,
);
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(&params),
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(&params), 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], &registry);
// 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(&registry), 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(&params),
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");
}