Files
optimclaw/src/tools/wasm/capabilities_schema.rs
T
Illia PolosukhinandClaude Opus 4.6 2e62d71567 feat: Add NEAR key management with transaction signing and policy engine
Implements hybrid-custody NEAR key management where the agent holds scoped
function-call keys for routine operations while high-value operations require
explicit user approval through the existing channel approval flow.

Core infrastructure:
- Ed25519 key generation/import via ed25519-dalek (not near-crypto)
- AES-256-GCM encrypted storage via existing SecretsStore
- Hand-rolled borsh-serializable NEAR transaction types
- NEP-413 intent signing and MPC chain signature support
- Configurable policy engine with transaction analysis pipeline
- Daily spend tracking with automatic midnight UTC reset
- Encrypted backup/restore with Argon2id KDF
- CLI subcommands: generate, import, list, info, remove, export, policy, backup, restore
- NEAR ed25519 secret key leak detection (Critical/Block)
- WASM sign-payload host function (keys never enter WASM memory)
- KeyManager wired into AgentDeps for agent-wide access

Security invariants: private keys never reach the LLM or WASM boundary,
signing happens in host Rust code with Zeroize on drop, every transaction
is analyzed before signing, most-restrictive policy rule wins.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-09 16:24:47 -08:00

852 lines
26 KiB
Rust

//! JSON schema for WASM tool capabilities files.
//!
//! External WASM tools declare their required capabilities via a sidecar JSON file
//! (e.g., `slack.capabilities.json`). This module defines the schema for those files
//! and provides conversion to runtime [`Capabilities`].
//!
//! # Example Capabilities File
//!
//! ```json
//! {
//! "http": {
//! "allowlist": [
//! { "host": "slack.com", "path_prefix": "/api/", "methods": ["GET", "POST"] }
//! ],
//! "credentials": {
//! "slack_bot_token": {
//! "secret_name": "slack_bot_token",
//! "location": { "type": "bearer" },
//! "host_patterns": ["slack.com"]
//! }
//! },
//! "rate_limit": { "requests_per_minute": 50, "requests_per_hour": 1000 }
//! },
//! "secrets": {
//! "allowed_names": ["slack_bot_token"]
//! }
//! }
//! ```
use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::secrets::{CredentialLocation, CredentialMapping};
use crate::tools::wasm::{
Capabilities, EndpointPattern, HttpCapability, RateLimitConfig, SecretsCapability,
SigningCapability, ToolInvokeCapability, WorkspaceCapability,
};
/// Root schema for a capabilities JSON file.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CapabilitiesFile {
/// HTTP request capability.
#[serde(default)]
pub http: Option<HttpCapabilitySchema>,
/// Secret existence checks.
#[serde(default)]
pub secrets: Option<SecretsCapabilitySchema>,
/// Tool invocation via aliases.
#[serde(default)]
pub tool_invoke: Option<ToolInvokeCapabilitySchema>,
/// Workspace file read access.
#[serde(default)]
pub workspace: Option<WorkspaceCapabilitySchema>,
/// Payload signing using managed NEAR keys.
#[serde(default)]
pub signing: Option<SigningCapabilitySchema>,
/// Authentication setup instructions.
/// Used by `ironclaw config` to guide users through auth setup.
#[serde(default)]
pub auth: Option<AuthCapabilitySchema>,
}
impl CapabilitiesFile {
/// Parse from JSON string.
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
/// Parse from JSON bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(bytes)
}
/// Convert to runtime Capabilities.
pub fn to_capabilities(&self) -> Capabilities {
let mut caps = Capabilities::default();
if let Some(http) = &self.http {
caps.http = Some(http.to_http_capability());
}
if let Some(secrets) = &self.secrets {
caps.secrets = Some(SecretsCapability {
allowed_names: secrets.allowed_names.clone(),
});
}
if let Some(tool_invoke) = &self.tool_invoke {
caps.tool_invoke = Some(ToolInvokeCapability {
aliases: tool_invoke.aliases.clone(),
rate_limit: tool_invoke
.rate_limit
.as_ref()
.map(|r| r.to_rate_limit_config())
.unwrap_or_default(),
});
}
if let Some(workspace) = &self.workspace {
caps.workspace_read = Some(WorkspaceCapability {
allowed_prefixes: workspace.allowed_prefixes.clone(),
reader: None, // Injected at runtime
});
}
if let Some(signing) = &self.signing {
caps.signing = Some(SigningCapability {
allowed_key_labels: signing.allowed_key_labels.clone(),
max_signs_per_execution: signing.max_signs_per_execution.unwrap_or(5),
signer: None, // Injected at runtime
});
}
caps
}
}
/// HTTP capability schema.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HttpCapabilitySchema {
/// Allowed endpoint patterns.
#[serde(default)]
pub allowlist: Vec<EndpointPatternSchema>,
/// Credential mappings (key is an identifier, not the secret name).
#[serde(default)]
pub credentials: HashMap<String, CredentialMappingSchema>,
/// Rate limiting configuration.
#[serde(default)]
pub rate_limit: Option<RateLimitSchema>,
/// Maximum request body size in bytes.
#[serde(default)]
pub max_request_bytes: Option<usize>,
/// Maximum response body size in bytes.
#[serde(default)]
pub max_response_bytes: Option<usize>,
/// Request timeout in seconds.
#[serde(default)]
pub timeout_secs: Option<u64>,
}
impl HttpCapabilitySchema {
fn to_http_capability(&self) -> HttpCapability {
let mut cap = HttpCapability {
allowlist: self
.allowlist
.iter()
.map(|p| p.to_endpoint_pattern())
.collect(),
credentials: self
.credentials
.values()
.map(|m| (m.secret_name.clone(), m.to_credential_mapping()))
.collect(),
rate_limit: self
.rate_limit
.as_ref()
.map(|r| r.to_rate_limit_config())
.unwrap_or_default(),
..Default::default()
};
if let Some(max) = self.max_request_bytes {
cap.max_request_bytes = max;
}
if let Some(max) = self.max_response_bytes {
cap.max_response_bytes = max;
}
if let Some(secs) = self.timeout_secs {
cap.timeout = Duration::from_secs(secs);
}
cap
}
}
/// Endpoint pattern schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EndpointPatternSchema {
/// Hostname (e.g., "api.slack.com" or "*.slack.com").
pub host: String,
/// Optional path prefix (e.g., "/api/").
#[serde(default)]
pub path_prefix: Option<String>,
/// Allowed HTTP methods (empty = all).
#[serde(default)]
pub methods: Vec<String>,
}
impl EndpointPatternSchema {
fn to_endpoint_pattern(&self) -> EndpointPattern {
EndpointPattern {
host: self.host.clone(),
path_prefix: self.path_prefix.clone(),
methods: self.methods.clone(),
}
}
}
/// Credential mapping schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CredentialMappingSchema {
/// Name of the secret to inject.
pub secret_name: String,
/// Where to inject the credential.
pub location: CredentialLocationSchema,
/// Host patterns this credential applies to.
#[serde(default)]
pub host_patterns: Vec<String>,
}
impl CredentialMappingSchema {
fn to_credential_mapping(&self) -> CredentialMapping {
CredentialMapping {
secret_name: self.secret_name.clone(),
location: self.location.to_credential_location(),
host_patterns: self.host_patterns.clone(),
}
}
}
/// Credential injection location schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CredentialLocationSchema {
/// Bearer token in Authorization header.
Bearer,
/// Basic auth (password from secret, username in config).
Basic { username: String },
/// Custom header.
Header {
name: String,
#[serde(default)]
prefix: Option<String>,
},
/// Query parameter.
QueryParam { name: String },
/// URL/path placeholder replacement.
UrlPath { placeholder: String },
}
impl CredentialLocationSchema {
fn to_credential_location(&self) -> CredentialLocation {
match self {
CredentialLocationSchema::Bearer => CredentialLocation::AuthorizationBearer,
CredentialLocationSchema::Basic { username } => {
CredentialLocation::AuthorizationBasic {
username: username.clone(),
}
}
CredentialLocationSchema::Header { name, prefix } => CredentialLocation::Header {
name: name.clone(),
prefix: prefix.clone(),
},
CredentialLocationSchema::QueryParam { name } => {
CredentialLocation::QueryParam { name: name.clone() }
}
CredentialLocationSchema::UrlPath { placeholder } => CredentialLocation::UrlPath {
placeholder: placeholder.clone(),
},
}
}
}
/// Rate limit schema.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitSchema {
/// Maximum requests per minute.
#[serde(default = "default_requests_per_minute")]
pub requests_per_minute: u32,
/// Maximum requests per hour.
#[serde(default = "default_requests_per_hour")]
pub requests_per_hour: u32,
}
fn default_requests_per_minute() -> u32 {
60
}
fn default_requests_per_hour() -> u32 {
1000
}
impl RateLimitSchema {
fn to_rate_limit_config(&self) -> RateLimitConfig {
RateLimitConfig {
requests_per_minute: self.requests_per_minute,
requests_per_hour: self.requests_per_hour,
}
}
}
/// Secrets capability schema.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SecretsCapabilitySchema {
/// Secret names the tool can check existence of (supports glob).
#[serde(default)]
pub allowed_names: Vec<String>,
}
/// Tool invocation capability schema.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ToolInvokeCapabilitySchema {
/// Mapping from alias to real tool name.
#[serde(default)]
pub aliases: HashMap<String, String>,
/// Rate limiting for tool calls.
#[serde(default)]
pub rate_limit: Option<RateLimitSchema>,
}
/// Signing capability schema.
///
/// Allows WASM tools to request payload signatures from managed NEAR keys.
/// The private keys never enter WASM memory.
///
/// # Example
///
/// ```json
/// {
/// "signing": {
/// "allowed_key_labels": ["intents-signer"],
/// "max_signs_per_execution": 5
/// }
/// }
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SigningCapabilitySchema {
/// Key labels this tool is allowed to use for signing.
#[serde(default)]
pub allowed_key_labels: Vec<String>,
/// Maximum sign operations per execution (default: 5).
#[serde(default)]
pub max_signs_per_execution: Option<u32>,
}
/// Workspace read capability schema.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkspaceCapabilitySchema {
/// Allowed path prefixes (e.g., ["context/", "daily/"]).
#[serde(default)]
pub allowed_prefixes: Vec<String>,
}
/// Authentication setup schema.
///
/// Tools declare their auth requirements here. The agent uses this to provide
/// generic auth flows without needing service-specific code in the main codebase.
///
/// Supports two auth methods:
/// 1. **OAuth** - Browser-based login (preferred for user-facing services)
/// 2. **Manual** - Copy/paste token from provider's dashboard
///
/// # Example (OAuth)
///
/// ```json
/// {
/// "auth": {
/// "secret_name": "notion_api_token",
/// "display_name": "Notion",
/// "oauth": {
/// "authorization_url": "https://api.notion.com/v1/oauth/authorize",
/// "token_url": "https://api.notion.com/v1/oauth/token",
/// "client_id": "your-client-id",
/// "scopes": []
/// },
/// "env_var": "NOTION_TOKEN"
/// }
/// }
/// ```
///
/// # Example (Manual)
///
/// ```json
/// {
/// "auth": {
/// "secret_name": "openai_api_key",
/// "display_name": "OpenAI",
/// "instructions": "Get your API key from platform.openai.com/api-keys",
/// "setup_url": "https://platform.openai.com/api-keys",
/// "token_hint": "Starts with 'sk-'",
/// "env_var": "OPENAI_API_KEY"
/// }
/// }
/// ```
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AuthCapabilitySchema {
/// Name of the secret to store (e.g., "notion_api_token").
/// Must match the secret_name in credentials if HTTP capability is used.
pub secret_name: String,
/// Human-readable name for the service (e.g., "Notion", "Slack").
#[serde(default)]
pub display_name: Option<String>,
/// OAuth configuration for browser-based login.
/// If present, OAuth flow is used instead of manual token entry.
#[serde(default)]
pub oauth: Option<OAuthConfigSchema>,
/// Instructions shown to the user for obtaining credentials (manual flow).
/// Can include markdown formatting.
#[serde(default)]
pub instructions: Option<String>,
/// URL to open for setting up credentials (manual flow).
#[serde(default)]
pub setup_url: Option<String>,
/// Hint about expected token format (e.g., "Starts with 'sk-'").
/// Used for validation feedback.
#[serde(default)]
pub token_hint: Option<String>,
/// Environment variable to check before prompting.
/// If this env var is set, its value is used automatically.
#[serde(default)]
pub env_var: Option<String>,
/// Provider hint for organizing secrets (e.g., "notion", "openai").
#[serde(default)]
pub provider: Option<String>,
/// Validation endpoint to check if the token works.
/// Tool can specify an endpoint to call for validation.
#[serde(default)]
pub validation_endpoint: Option<ValidationEndpointSchema>,
}
/// OAuth 2.0 configuration for browser-based login.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OAuthConfigSchema {
/// OAuth authorization URL (e.g., "https://api.notion.com/v1/oauth/authorize").
pub authorization_url: String,
/// OAuth token exchange URL (e.g., "https://api.notion.com/v1/oauth/token").
pub token_url: String,
/// OAuth client ID.
/// Can be set here or via environment variable (see client_id_env).
#[serde(default)]
pub client_id: Option<String>,
/// Environment variable containing the client ID.
/// Checked if client_id is not set directly.
#[serde(default)]
pub client_id_env: Option<String>,
/// OAuth client secret (optional, some providers don't require it with PKCE).
/// Can be set here or via environment variable (see client_secret_env).
#[serde(default)]
pub client_secret: Option<String>,
/// Environment variable containing the client secret.
/// Checked if client_secret is not set directly.
#[serde(default)]
pub client_secret_env: Option<String>,
/// OAuth scopes to request.
#[serde(default)]
pub scopes: Vec<String>,
/// Use PKCE (Proof Key for Code Exchange). Defaults to true.
/// Required for public clients (CLI tools).
#[serde(default = "default_true")]
pub use_pkce: bool,
/// Additional parameters to include in the authorization URL.
#[serde(default)]
pub extra_params: std::collections::HashMap<String, String>,
/// Field name in token response containing the access token.
/// Defaults to "access_token".
#[serde(default = "default_access_token_field")]
pub access_token_field: String,
}
fn default_true() -> bool {
true
}
fn default_access_token_field() -> String {
"access_token".to_string()
}
/// Schema for token validation endpoint.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ValidationEndpointSchema {
/// URL to call for validation (e.g., "https://api.notion.com/v1/users/me").
pub url: String,
/// HTTP method (defaults to GET).
#[serde(default = "default_method")]
pub method: String,
/// Expected HTTP status code for success (defaults to 200).
#[serde(default = "default_success_status")]
pub success_status: u16,
}
fn default_method() -> String {
"GET".to_string()
}
fn default_success_status() -> u16 {
200
}
#[cfg(test)]
mod tests {
use crate::tools::wasm::capabilities_schema::{CapabilitiesFile, CredentialLocationSchema};
#[test]
fn test_parse_minimal() {
let json = "{}";
let caps = CapabilitiesFile::from_json(json).unwrap();
assert!(caps.http.is_none());
assert!(caps.secrets.is_none());
}
#[test]
fn test_parse_http_allowlist() {
let json = r#"{
"http": {
"allowlist": [
{ "host": "api.slack.com", "path_prefix": "/api/", "methods": ["GET", "POST"] }
]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
assert_eq!(http.allowlist.len(), 1);
assert_eq!(http.allowlist[0].host, "api.slack.com");
assert_eq!(http.allowlist[0].path_prefix, Some("/api/".to_string()));
assert_eq!(http.allowlist[0].methods, vec!["GET", "POST"]);
}
#[test]
fn test_parse_credentials() {
let json = r#"{
"http": {
"allowlist": [{ "host": "slack.com" }],
"credentials": {
"slack": {
"secret_name": "slack_bot_token",
"location": { "type": "bearer" },
"host_patterns": ["slack.com", "*.slack.com"]
}
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
assert_eq!(http.credentials.len(), 1);
let cred = http.credentials.get("slack").unwrap();
assert_eq!(cred.secret_name, "slack_bot_token");
assert!(matches!(cred.location, CredentialLocationSchema::Bearer));
assert_eq!(cred.host_patterns, vec!["slack.com", "*.slack.com"]);
}
#[test]
fn test_parse_custom_header_credential() {
let json = r#"{
"http": {
"allowlist": [{ "host": "api.example.com" }],
"credentials": {
"api_key": {
"secret_name": "my_api_key",
"location": { "type": "header", "name": "X-API-Key", "prefix": "Key " },
"host_patterns": ["api.example.com"]
}
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
let cred = http.credentials.get("api_key").unwrap();
match &cred.location {
CredentialLocationSchema::Header { name, prefix } => {
assert_eq!(name, "X-API-Key");
assert_eq!(prefix, &Some("Key ".to_string()));
}
_ => panic!("Expected Header location"),
}
}
#[test]
fn test_parse_url_path_credential() {
let json = r#"{
"http": {
"allowlist": [{ "host": "api.telegram.org" }],
"credentials": {
"telegram_bot": {
"secret_name": "telegram_bot_token",
"location": {
"type": "url_path",
"placeholder": "{TELEGRAM_BOT_TOKEN}"
},
"host_patterns": ["api.telegram.org"]
}
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let http = caps.http.unwrap();
let cred = http.credentials.get("telegram_bot").unwrap();
match &cred.location {
CredentialLocationSchema::UrlPath { placeholder } => {
assert_eq!(placeholder, "{TELEGRAM_BOT_TOKEN}");
}
_ => panic!("Expected UrlPath location"),
}
}
#[test]
fn test_parse_secrets_capability() {
let json = r#"{
"secrets": {
"allowed_names": ["slack_*", "openai_key"]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let secrets = caps.secrets.unwrap();
assert_eq!(secrets.allowed_names, vec!["slack_*", "openai_key"]);
}
#[test]
fn test_parse_tool_invoke() {
let json = r#"{
"tool_invoke": {
"aliases": {
"search": "brave_search",
"calc": "calculator"
},
"rate_limit": {
"requests_per_minute": 10,
"requests_per_hour": 100
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let tool_invoke = caps.tool_invoke.unwrap();
assert_eq!(
tool_invoke.aliases.get("search"),
Some(&"brave_search".to_string())
);
let rate = tool_invoke.rate_limit.unwrap();
assert_eq!(rate.requests_per_minute, 10);
}
#[test]
fn test_parse_workspace() {
let json = r#"{
"workspace": {
"allowed_prefixes": ["context/", "daily/"]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let workspace = caps.workspace.unwrap();
assert_eq!(workspace.allowed_prefixes, vec!["context/", "daily/"]);
}
#[test]
fn test_to_capabilities() {
let json = r#"{
"http": {
"allowlist": [{ "host": "api.slack.com", "path_prefix": "/api/" }],
"rate_limit": { "requests_per_minute": 50, "requests_per_hour": 500 }
},
"secrets": {
"allowed_names": ["slack_token"]
}
}"#;
let file = CapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
assert!(caps.http.is_some());
let http = caps.http.unwrap();
assert_eq!(http.allowlist.len(), 1);
assert_eq!(http.rate_limit.requests_per_minute, 50);
assert!(caps.secrets.is_some());
let secrets = caps.secrets.unwrap();
assert!(secrets.is_allowed("slack_token"));
}
#[test]
fn test_full_slack_example() {
let json = r#"{
"http": {
"allowlist": [
{ "host": "slack.com", "path_prefix": "/api/", "methods": ["GET", "POST"] }
],
"credentials": {
"slack_bot_token": {
"secret_name": "slack_bot_token",
"location": { "type": "bearer" },
"host_patterns": ["slack.com"]
}
},
"rate_limit": { "requests_per_minute": 50, "requests_per_hour": 1000 }
},
"secrets": {
"allowed_names": ["slack_bot_token"]
}
}"#;
let file = CapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
let http = caps.http.unwrap();
assert_eq!(http.allowlist[0].host, "slack.com");
assert!(http.credentials.contains_key("slack_bot_token"));
let secrets = caps.secrets.unwrap();
assert!(secrets.is_allowed("slack_bot_token"));
}
#[test]
fn test_parse_auth_capability() {
let json = r#"{
"auth": {
"secret_name": "notion_api_token",
"display_name": "Notion",
"instructions": "Create an integration at notion.so/my-integrations",
"setup_url": "https://www.notion.so/my-integrations",
"token_hint": "Starts with 'secret_' or 'ntn_'",
"env_var": "NOTION_TOKEN",
"provider": "notion",
"validation_endpoint": {
"url": "https://api.notion.com/v1/users/me",
"method": "GET",
"success_status": 200
}
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let auth = caps.auth.unwrap();
assert_eq!(auth.secret_name, "notion_api_token");
assert_eq!(auth.display_name, Some("Notion".to_string()));
assert_eq!(auth.env_var, Some("NOTION_TOKEN".to_string()));
assert_eq!(auth.provider, Some("notion".to_string()));
let validation = auth.validation_endpoint.unwrap();
assert_eq!(validation.url, "https://api.notion.com/v1/users/me");
assert_eq!(validation.method, "GET");
assert_eq!(validation.success_status, 200);
}
#[test]
fn test_parse_auth_minimal() {
let json = r#"{
"auth": {
"secret_name": "my_api_key"
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let auth = caps.auth.unwrap();
assert_eq!(auth.secret_name, "my_api_key");
assert!(auth.display_name.is_none());
assert!(auth.setup_url.is_none());
}
#[test]
fn test_parse_signing_capability() {
let json = r#"{
"signing": {
"allowed_key_labels": ["intents-signer", "trading-key"],
"max_signs_per_execution": 10
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let signing = caps.signing.unwrap();
assert_eq!(
signing.allowed_key_labels,
vec!["intents-signer", "trading-key"]
);
assert_eq!(signing.max_signs_per_execution, Some(10));
}
#[test]
fn test_parse_signing_defaults() {
let json = r#"{
"signing": {
"allowed_key_labels": ["default"]
}
}"#;
let caps = CapabilitiesFile::from_json(json).unwrap();
let signing = caps.signing.as_ref().unwrap();
assert_eq!(signing.max_signs_per_execution, None);
// Should default to 5 when converted
let runtime_caps = caps.to_capabilities();
let runtime_signing = runtime_caps.signing.unwrap();
assert_eq!(runtime_signing.max_signs_per_execution, 5);
assert!(runtime_signing.is_label_allowed("default"));
assert!(!runtime_signing.is_label_allowed("other"));
}
#[test]
fn test_signing_to_capabilities() {
let json = r#"{
"signing": {
"allowed_key_labels": ["signer-1"],
"max_signs_per_execution": 3
}
}"#;
let file = CapabilitiesFile::from_json(json).unwrap();
let caps = file.to_capabilities();
let signing = caps.signing.unwrap();
assert_eq!(signing.allowed_key_labels, vec!["signer-1"]);
assert_eq!(signing.max_signs_per_execution, 3);
assert!(signing.signer.is_none()); // Injected at runtime
}
}