Files
ironclaw/tests/skill_credential_injection.rs
Pranav Raja 49f3e8d566 feat(credentials): path-based credential matching for per-endpoint auth (#2168)
* feat(credentials): path-based credential matching for per-endpoint auth

Add `path_patterns` field to `CredentialMapping` to scope credentials to
specific URL path prefixes on a host. When set, the request path must
match a prefix at a segment boundary (`/` or `?`). When empty (default),
credentials match all paths on the host — fully backwards compatible.

Key changes:
- `CredentialMapping.matches(host, path)` with segment-boundary enforcement
- `path_matches_prefix()` rejects `..` traversal, normalizes trailing slashes
- `host_matches_pattern()` deduplicated to single source in secrets/types.rs,
  case-insensitive per RFC 4343
- HTTP tool uses `find_for_url(host, path)` for path-aware credential lookup
- Auth manager pre-flight check uses path-aware `find_for_url`
- WASM tool/channel wrappers carry `path_patterns` through to injection time
- `CredentialMappingSchema`, `SkillCredentialSpec` support `path_patterns`

Tests cover segment-boundary attacks, path traversal rejection, case-insensitive
host matching, path-scoped injection, and different credentials for different
paths on the same host.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(credentials): address PR #2168 review feedback

- Narrow `..` rejection in path_matches_prefix to per-segment so
  legitimate paths like /api/..config are no longer falsely blocked
  (path_matches_prefix was using path.contains("..")).
- Path-scope credential injection in the channels WASM wrapper:
  ResolvedHostCredential now carries path_patterns and
  inject_host_credentials honors it, matching the tools-side wrapper.
- Add path-aware CredentialInjector API (find_credentials_for_url /
  inject_for_url); deprecate the host-only variants and
  SharedCredentialRegistry::find_for_host with #[deprecated] attrs.
- Tighten CredentialMappingSchema.path_patterns from
  Option<Vec<String>> to #[serde(default)] Vec<String>, matching
  sibling types.
- Validate path_patterns in validate_credential_spec: require
  leading '/', reject empty, reject '..' as a segment.
- Expand comment in http.rs documenting why LLM-header blocking is
  host-scoped (exfil defense) while injection is path-scoped
  (minimum privilege).

Tests: +5 validation cases, +2 injector cases, +2 channel wrapper
cases, +2 path_matches_prefix cases covering dot-dot-inside-segment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(credentials): address PR #2168 round-3 review

- secrets/types: reject %2e / %2E in paths (percent-encoded traversal
  bypass for servers that decode before routing, e.g. IIS/Tomcat)
- sandbox/proxy/policy: find_credential now honors path_patterns via
  CredentialMapping::matches, using request.path (regression test added)
- wasm wrappers: extract shared extract_url_path_for_matching helper in
  secrets/types, with tracing::debug! on URL parse failure; removes
  duplicated 12-line block between tools/wasm and channels/wasm
- ironclaw_skills/validation: factor validate_path_pattern out of
  validate_credential_spec and reject '?' and '#' in path_patterns
  (Url::path() strips them, so these silently never match)
- tools/wasm/capabilities_schema: plumb the same validate_path_pattern
  through the WASM manifest loader — bad patterns log as warnings
  instead of silently failing to match
- tools/builtin/http: unit tests for extract_path_from_params (valid,
  missing url, query+fragment stripping, bare host, malformed)
- tests/skill_credential_injection: caller-level tests driving
  HttpTool::execute with path-scoped credentials — segment boundary
  and auth-gap-on-non-matching-path (per .claude/rules/testing.md
  "Test Through the Caller, Not Just the Helper")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(credentials): address PR #2168 round-4 review

- secrets/types: path_matches_prefix now decodes each segment and rejects
  only dot-segments (literal . or .., plus percent-encoded equivalents
  like %2e, %2e%2e, mixed case, .%2e, %2e.). Legitimate literal paths
  with embedded encoded dots — /files/foo%2ebar → foo.bar,
  /releases/v1%2e2 → v1.2 — are now allowed. Replaces the previous
  "reject any %2e substring" rule which over-rejected normal filenames.
  (Firat #3125964627)

- secrets/types: add match_specificity(path_patterns, req_path) returning
  the length of the longest matching prefix (0 if unscoped). Exported
  pub(crate) for callers that need deterministic credential precedence.

- credential_injector + both wasm wrappers: sort matching credentials by
  ascending path specificity, tie-broken alphabetically on secret_name,
  before the last-write-wins header merge. The most-specific mapping now
  wins any header conflict regardless of HashMap iteration order, which
  fixes nondeterministic winner selection on overlapping mappings.
  ResolvedHostCredential gains a secret_name field purely for stable
  tie-breaks (no secret material exposed). (Firat #3125963270)

- bridge/auth_manager::check_http_auth: replace "return Ready on first
  resolved mapping" short-circuit with conjunctive evaluation — every
  non-optional matched mapping must resolve for Ready. Optional mappings
  are skipped. Missing required credentials are accumulated and returned
  as MissingCredentials so endpoints needing bearer + org-header surface
  the auth gate instead of failing at the wire with a raw 401.
  (Firat #3125963977)

- tools/builtin/http::execute: stop clearing missing_credential on peer
  success and drop the !injected_any_credential guard. Track the first
  missing required credential for the 401/403 remediation UX; skip
  optional mappings. Matches the new auth_manager behavior.

Regression tests:
- secrets/types: path_matches_prefix_rejects_percent_encoded_dot_segments
  (adds .%2e and %2e. mixed-form cases), path_matches_prefix_allows_legit_
  embedded_encoded_dot (foo%2ebar, v1%2e2), match_specificity_ranks_
  longer_prefixes_higher.
- tools/wasm/wrapper: test_inject_host_credentials_most_specific_path_wins
  verifies the sort is order-independent by constructing the same creds
  in both orders and asserting the specific one wins.
- bridge/auth_manager: check_http_conjunctive_auth_any_missing_required_
  raises_gate, check_http_conjunctive_auth_all_required_resolved_is_ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: cargo fmt after round-3/round-4 review fixes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: untrack accidentally-committed local scratch files

Follow-up to 50f6771f, which inadvertently staged local-only scratch
files via `git add -A`:
- .DS_Store
- tests_all/ — operator scratch dir with env files (rotate any tokens
  that were in tests_all/source_env_vars.sh in that commit)
- integrations/abound/tests/source_env_vars.sh — same concern
- src/cli/snapshots/*.snap.new — stale insta snapshot proposals that
  should be resolved via `cargo insta review`, not committed

This commit removes them from the index and adds matching patterns to
.gitignore so they cannot recur. The content is left on disk for local
use.

NOTE: the tokens that were in the env files are still visible in the
50f6771f commit object and must be rotated regardless of this cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(credentials): address PR #2168 round-5 review

- secrets/types: tighten the percent-decoded dot-segment check to also
  reject segments whose decoded form contains `/` (percent-encoded
  slash, e.g. `%2f`). Blocks `%2e%2e%2fadmin`-style smuggling on
  servers that decode encoded slashes before routing (Tomcat with
  `allowEncodedSlash=true`, older IIS, certain reverse proxies).
  Literal dots inside a single segment (`foo%2ebar`, `v1%2e2`) stay
  allowed. (Firat #3126256056)
- sandbox/proxy/policy: `find_credential` now returns the most-specific
  matching mapping via `max_by(...)` on `(match_specificity, secret_name)`
  rather than `.find(...)`. Behavior matches `SharedCredentialRegistry::
  find_for_url` and both WASM `inject_host_credentials` — a host with a
  global + a `/api/v1/write` credential picks WRITE_TOKEN on writes
  regardless of Vec order. (Firat #3126256060)
- tools/wasm/capabilities_schema: `to_credential_mapping` now returns
  `Option` and `to_http_capability` filter_maps invalid mappings out
  entirely. Previously `path_patterns: [""]` survived the warning-only
  validator and silently widened the credential back to global scope
  (`path_matches_prefix(path, "")` → true for every absolute path).
  The drop-on-invalid semantics match the skills pipeline. Removed the
  duplicate warn-loop in `validate()` since the load path now handles
  it. (Firat #3126256040)

Regression tests:
- secrets/types: path_matches_prefix_rejects_percent_encoded_slash_
  smuggling covers lowercase/uppercase `%2f` in traversal and embedded
  positions.
- sandbox/proxy/policy: test_sandbox_proxy_most_specific_credential_
  wins registers the same credentials in both orders and asserts
  WRITE_TOKEN always wins on the write path.
- tools/wasm/capabilities_schema: three tests covering empty-string
  pattern (drops mapping), missing-leading-slash (drops mapping), and
  valid pattern (preserved).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:58:26 -07:00

894 lines
33 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::context::JobContext;
use ironclaw::secrets::{
CreateSecretParams, CredentialLocation, CredentialMapping, InMemorySecretsStore, SecretsCrypto,
SecretsStore,
};
use ironclaw::tools::builtin::HttpTool;
use ironclaw::tools::wasm::SharedCredentialRegistry;
use ironclaw::tools::{ApprovalRequirement, Tool, ToolError};
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,
requires: ironclaw_skills::GatingRequirements::default(),
},
prompt_content: prompt.to_string(),
trust: SkillTrust::Trusted,
source: SkillSource::User(PathBuf::from("/tmp/test-skills")), // safety: dummy path in test, not used for I/O
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()],
path_patterns: Vec::new(),
oauth: Some(SkillOAuthConfig {
authorization_url: "http://insecure.example.com/auth".to_string(),
token_url: "https://secure.example.com/token".to_string(),
client_id: None,
client_id_env: None,
client_secret: None,
client_secret_env: None,
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![],
path_patterns: Vec::new(),
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()],
path_patterns: Vec::new(),
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()],
path_patterns: Vec::new(),
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![],
path_patterns: Vec::new(),
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()],
path_patterns: Vec::new(),
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()],
path_patterns: Vec::new(),
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()],
path_patterns: Vec::new(),
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()],
path_patterns: Vec::new(),
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()],
path_patterns: Vec::new(),
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()],
path_patterns: Vec::new(),
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()],
path_patterns: Vec::new(),
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()],
path_patterns: Vec::new(),
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_url("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");
}
// ── Behaviors #6/#7/#8: header rejection at execute() time ───────────────
//
// The doc comment at the top of this file enumerates 10 behaviors the
// pipeline guarantees. The three below are the actual defenses against
// LLM-mediated credential exfiltration:
//
// #6 LLM-provided auth headers are REJECTED for registered hosts
// #7 Non-auth headers PASS THROUGH for registered hosts
// #8 Unregistered hosts ALLOW LLM-supplied auth headers
//
// `requires_approval` is exercised elsewhere (test #5); these tests drive
// `HttpTool::execute()` directly so they hit the
// `block LLM-provided authorization headers` branch in `http.rs:503-520`,
// not just the upstream gating layer. Each test relies on the rejection
// happening BEFORE any network call so we never need a real HTTP server.
fn make_registry_with_github() -> Arc<SharedCredentialRegistry> {
let registry = Arc::new(SharedCredentialRegistry::new());
registry.add_mappings(vec![CredentialMapping::bearer(
"github_token",
"api.github.com",
)]);
registry
}
#[tokio::test]
async fn test_credentialed_host_rejects_llm_authorization_header() {
// Behavior #6: an LLM trying to set `Authorization` for a host that
// already has a registered credential mapping must be rejected with
// `NotAuthorized`. Otherwise a prompt-injection attack could exfiltrate
// arbitrary tokens by sending them to a known credentialed endpoint.
let tool =
http_tool_with_credentials(make_registry_with_github(), Arc::new(test_secrets_store()));
let ctx = JobContext::new("Test", "credentialed authorization header");
let cases: &[(&str, &str)] = &[
("Authorization", "Bearer ghp_attacker_supplied"),
("authorization", "Bearer ghp_attacker_supplied"),
("X-Api-Key", "k_attacker"),
("api-key", "k_attacker"),
("X-Auth-Token", "tok_attacker"),
];
for (name, value) in cases {
let params = serde_json::json!({
"method": "GET",
"url": "https://api.github.com/repos/nearai/ironclaw/issues",
"headers": [{ "name": *name, "value": *value }],
});
let err = tool
.execute(params, &ctx)
.await
.expect_err("LLM-supplied auth header should be rejected for credentialed host");
match err {
ToolError::NotAuthorized(msg) => {
assert!(
msg.to_ascii_lowercase()
.contains(&name.to_ascii_lowercase())
|| msg.contains("auto-injected"),
"rejection message should mention the offending header or the auto-injection rule, got: {msg}"
);
}
other => panic!("expected NotAuthorized for header '{name}', got: {other:?}"),
}
}
}
#[tokio::test]
async fn test_credentialed_host_allows_non_auth_headers() {
// Behavior #7: non-auth headers (Content-Type, Accept, custom headers
// that aren't on the forbidden list) must pass the auth check on a
// credentialed host. We assert that the rejection branch does NOT fire
// — the request will still fail at the network layer when it tries to
// reach api.github.com under test, but it must fail with something
// OTHER than NotAuthorized.
let tool =
http_tool_with_credentials(make_registry_with_github(), Arc::new(test_secrets_store()));
let ctx = JobContext::new("Test", "credentialed non-auth headers");
let params = serde_json::json!({
"method": "GET",
"url": "https://api.github.com/repos/nearai/ironclaw/issues",
"headers": [
{ "name": "Accept", "value": "application/vnd.github+json" },
{ "name": "Content-Type", "value": "application/json" },
{ "name": "X-Custom-Trace-Id", "value": "test-1234" },
],
});
let result = tool.execute(params, &ctx).await;
if let Err(ToolError::NotAuthorized(msg)) = &result {
panic!(
"non-auth headers must NOT be rejected on credentialed host; got NotAuthorized: {msg}"
);
}
// Any other outcome (Ok, ExternalService, Timeout, Sandbox) is fine —
// the test only asserts that the auth-rejection branch did not fire.
}
#[tokio::test]
async fn test_path_scoped_credential_strips_headers_on_non_matching_path() {
// Regression for Firat's round-3 finding: host-level header blocking and
// path-level injection are asymmetric by design — a host with any
// registered credential strips LLM-supplied auth headers on every path,
// but injection only fires on paths that match `path_patterns`. The net
// effect on a non-matching path is that the request goes out
// unauthenticated. This test locks that in so future refactors to either
// side (stripping OR injection) can't silently break the combined
// behavior.
//
// We can't assert "no Authorization header on the outbound wire" without
// a live spy server, but we CAN assert the branch behavior: a request to
// a non-matching path with an LLM-supplied `Authorization` header must
// be rejected (NotAuthorized) because the strip branch is host-scoped.
let registry = Arc::new(SharedCredentialRegistry::new());
registry.add_mappings(vec![CredentialMapping {
secret_name: "api_v1_write_token".to_string(),
location: CredentialLocation::AuthorizationBearer,
host_patterns: vec!["api.github.com".to_string()],
path_patterns: vec!["/api/v1/write".to_string()],
optional: false,
}]);
let tool = http_tool_with_credentials(registry, Arc::new(test_secrets_store()));
let ctx = JobContext::new("Test", "path-scoped auth gap");
// Request to the NON-matching path /api/v1/read with LLM-supplied auth.
// Host has a registered credential mapping, so the header-block path
// must fire regardless of whether the SPECIFIC path matches.
let params = serde_json::json!({
"method": "GET",
"url": "https://api.github.com/api/v1/read",
"headers": [{ "name": "Authorization", "value": "Bearer llm_supplied" }],
});
let err = tool
.execute(params, &ctx)
.await
.expect_err("LLM-supplied Authorization header must be blocked on credentialed host even when the specific path has no injectable credential");
assert!(
matches!(err, ToolError::NotAuthorized(_)),
"expected NotAuthorized on non-matching path for credentialed host, got {err:?}"
);
}
#[tokio::test]
async fn test_path_scoped_credential_wrong_path_is_segment_boundary_rejected() {
// Caller-level regression for `/api/v1-malicious` vs path_pattern
// `/api/v1`. A segment-boundary bug in `path_matches_prefix` would make
// the prefix match and inject the v1 credential on the malicious-suffix
// path. Verified end-to-end via `execute()` so the whole pipeline is
// exercised: URL parse → find_for_url → path_matches_prefix → inject.
//
// Assertion shape: request to /api/v1-malicious with an LLM-supplied
// auth header is still rejected (host-scoped strip) — but the test's
// real value is that it fails loudly if anyone ever loosens the prefix
// check and starts injecting on suffix-attack paths.
let registry = Arc::new(SharedCredentialRegistry::new());
registry.add_mappings(vec![CredentialMapping {
secret_name: "v1_token".to_string(),
location: CredentialLocation::AuthorizationBearer,
host_patterns: vec!["api.github.com".to_string()],
path_patterns: vec!["/api/v1".to_string()],
optional: false,
}]);
let tool = http_tool_with_credentials(registry, Arc::new(test_secrets_store()));
let ctx = JobContext::new("Test", "segment boundary via execute");
let params = serde_json::json!({
"method": "GET",
"url": "https://api.github.com/api/v1-malicious",
"headers": [{ "name": "Authorization", "value": "Bearer llm_supplied" }],
});
let err = tool
.execute(params, &ctx)
.await
.expect_err("LLM-supplied Authorization must be blocked on credentialed host");
assert!(
matches!(err, ToolError::NotAuthorized(_)),
"expected NotAuthorized for v1-malicious; got {err:?}"
);
}
#[tokio::test]
async fn test_unregistered_host_allows_llm_authorization_header() {
// Behavior #8: when the destination host has NO registered credential
// mapping, LLM-supplied auth headers must pass the auth check. The
// rationale: rejecting them would prevent legitimate single-shot API
// calls to user-specified hosts that the credential system doesn't
// know about. Same assertion shape as #7 — we only verify the
// NotAuthorized branch does not fire, not that the request actually
// reaches the wire.
let tool =
http_tool_with_credentials(make_registry_with_github(), Arc::new(test_secrets_store()));
let ctx = JobContext::new("Test", "unregistered host with auth header");
// api.example.com is NOT in the registry, only api.github.com is.
let params = serde_json::json!({
"method": "GET",
"url": "https://api.example.com/widgets",
"headers": [
{ "name": "Authorization", "value": "Bearer user_supplied_token" },
],
});
let result = tool.execute(params, &ctx).await;
if let Err(ToolError::NotAuthorized(msg)) = &result {
panic!("unregistered host must allow LLM-supplied auth headers; got NotAuthorized: {msg}");
}
}