fix(ironhub): carry published credential recipes into the generated manifest

An IronHub tool that needs a credential installed "successfully" and could never
authenticate. Attio is the reported case: it was installed, activated, and
callable as attio.invoke, but nothing in the extension model knew an API key was
required, so no auth challenge was raised and the in-chat credential card never
rendered. The agent filled the gap by inventing a CLI command.

Cause: `generic_tool_manifest` synthesised the v3 manifest from the catalog
entry's name/version/description alone and hardcoded `effects = ["network"]`.
The tool's own credential recipe travels in the capabilities artifact — already
downloaded, digest-verified, and written into the package as
`legacy/capabilities.json` — and was never read. Attio publishes:

  "http": { "credentials": { "attio_api_key": {
      "location": { "type": "bearer" }, "host_patterns": ["api.attio.com"] } } }

`mapped_credentials` now reads that and emits a `[[tools.credentials]]` block
plus the `use_secret` effect, matching how bundled first-party extensions
(github, slack) declare credentials. Location mapping, from a survey of all nine
credentialed catalog tools:

- bearer (7 tools) -> header "authorization" with prefix "Bearer "
- header (monday)  -> that header name, NO prefix; monday.com sends the raw token
                      as the Authorization value, so an invented prefix would
                      break every request
- basic  (wazuh)   -> UNSUPPORTED: v3 injection models header/query/path/pointer,
                      not HTTP Basic. Fails the install with a message naming the
                      tool and the location type, rather than repeating the
                      silent-success failure this change fixes.

Policy fields stay host-authored. `trust`, `origin_gate_matrix`,
`default_permission` and `visibility` remain hardcoded: a third-party package
declares which credential it needs, never what it is allowed to do. That is why
generation is kept and the tool's own manifest.toml is still not trusted.

Tests cover each location shape, the credential-free path (unchanged output),
and that the generated manifest parses as real v3 with the credential block and
use_secret effect present — not just that the string contains them.

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>
This commit is contained in:
serrrfirat
2026-07-29 11:39:14 +03:00
parent 67f6eb4276
commit e53ecc0618

View File

@@ -16,8 +16,13 @@ pub(crate) fn ironhub_tool_package(
let module_path = format!("wasm/{}_tool.wasm", entry.crate_name);
let input_schema_path = format!("schemas/{}/invoke.input.v1.json", entry.name);
let output_schema_path = format!("schemas/{}/raw_output.v1.json", entry.name);
let manifest =
generic_tool_manifest(entry, &module_path, &input_schema_path, &output_schema_path);
let manifest = generic_tool_manifest_with_credentials(
entry,
&module_path,
&input_schema_path,
&output_schema_path,
&capabilities,
)?;
registry_extension_package(
vec![
("manifest.toml".to_string(), manifest.into_bytes()),
@@ -31,11 +36,159 @@ pub(crate) fn ironhub_tool_package(
.map_err(IronHubCommandError::Product)
}
/// A credential the tool published in its signed capabilities artifact, mapped
/// onto the v3 injection contract.
struct MappedCredential {
handle: String,
host: String,
header: String,
/// `None` injects the raw secret as the header value: monday.com sends the
/// token as the Authorization value with no scheme prefix, so inventing a
/// "Bearer " prefix would break every request it makes.
prefix: Option<String>,
}
/// Read `http.credentials` out of the signed capabilities artifact.
///
/// The artifact is already downloaded and digest-verified; before this it was
/// written into the package as `legacy/capabilities.json` and never read, so
/// every credential recipe a tool published was discarded and the generated
/// manifest claimed the tool needed no secrets. A credentialed tool therefore
/// installed "successfully" and could never authenticate.
///
/// Policy fields (trust, origin gates, default permission, visibility) stay
/// host-authored: a third-party package declares which credential it needs,
/// never what it is allowed to do.
fn mapped_credentials(
entry: &IronHubToolEntry,
capabilities: &[u8],
) -> Result<Vec<MappedCredential>, IronHubCommandError> {
let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(capabilities) else {
// Policing the artifact's overall shape is not this function's contract;
// a package with no readable credential block installs as before.
return Ok(Vec::new());
};
let Some(declared) = parsed
.get("http")
.and_then(|http| http.get("credentials"))
.and_then(serde_json::Value::as_object)
else {
return Ok(Vec::new());
};
let mut mapped = Vec::new();
for (handle, credential) in declared {
let location = credential
.get("location")
.and_then(serde_json::Value::as_object);
let kind = location
.and_then(|location| location.get("type"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let (header, prefix) = match kind {
"bearer" => ("authorization".to_string(), Some("Bearer ".to_string())),
"header" => {
let name = location
.and_then(|location| location.get("name"))
.and_then(serde_json::Value::as_str)
.unwrap_or("authorization");
(name.to_ascii_lowercase(), None)
}
other => {
// Fail closed. v3 injection models header, query param, path
// placeholder and JSON pointer — not HTTP Basic. Installing
// anyway would recreate the exact failure this function fixes:
// a successful install that can never authenticate.
return Err(IronHubCommandError::Catalog {
reason: format!(
"'{}' declares credential '{handle}' with unsupported location type \
'{other}'; the host cannot inject it, so the tool would install \
without working authentication",
entry.name
),
});
}
};
let host = credential
.get("host_patterns")
.and_then(serde_json::Value::as_array)
.and_then(|hosts| hosts.first())
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
if host.is_empty() {
return Err(IronHubCommandError::Catalog {
reason: format!(
"'{}' declares credential '{handle}' without a host pattern; the \
injection audience cannot be bounded",
entry.name
),
});
}
mapped.push(MappedCredential {
handle: handle.clone(),
host,
header,
prefix,
});
}
// Deterministic order: the manifest is content-addressed through the package.
mapped.sort_by(|left, right| left.handle.cmp(&right.handle));
Ok(mapped)
}
fn credential_blocks(entry: &IronHubToolEntry, credentials: &[MappedCredential]) -> String {
credentials
.iter()
.map(|credential| {
let prefix = credential
.prefix
.as_ref()
.map(|prefix| format!(", prefix = {}", toml_string(prefix.clone())))
.unwrap_or_default();
format!(
"\n[[tools.credentials]]\nhandle = {handle}\nvendor = {vendor}\naudience = {{ scheme = \"https\", host = {host} }}\ninjection = {{ type = \"header\", name = {name}{prefix} }}\n",
handle = toml_string(credential.handle.clone()),
vendor = toml_string(entry.name.clone()),
host = toml_string(credential.host.clone()),
name = toml_string(credential.header.clone()),
)
})
.collect()
}
fn generic_tool_manifest_with_credentials(
entry: &IronHubToolEntry,
module_path: &str,
input_schema_path: &str,
output_schema_path: &str,
capabilities: &[u8],
) -> Result<String, IronHubCommandError> {
let credentials = mapped_credentials(entry, capabilities)?;
let effects = if credentials.is_empty() {
r#"["network"]"#
} else {
r#"["network", "use_secret"]"#
};
Ok(format!(
"{}{}",
generic_tool_manifest(
entry,
module_path,
input_schema_path,
output_schema_path,
effects,
),
credential_blocks(entry, &credentials),
))
}
fn generic_tool_manifest(
entry: &IronHubToolEntry,
module_path: &str,
input_schema_path: &str,
output_schema_path: &str,
effects: &str,
) -> String {
format!(
r#"schema_version = "reborn.extension_manifest.v3"
@@ -53,7 +206,7 @@ module = {module}
origin_gate_matrix = {{ loop_run = "gated_unless_granted", product = "forbidden", automation = "forbidden" }}
id = {capability_id}
description = {description}
effects = ["network"]
effects = {effects}
default_permission = "ask"
visibility = "model"
input_schema_ref = {input_schema_ref}
@@ -67,6 +220,7 @@ output_schema_ref = {output_schema_ref}
capability_id = toml_string(format!("{}.invoke", entry.name)),
input_schema_ref = toml_string(input_schema_path),
output_schema_ref = toml_string(output_schema_path),
effects = effects,
)
}
@@ -79,6 +233,171 @@ mod tests {
use super::*;
use crate::ironhub::model::{IronHubArtifact, IronHubProvenance};
fn caps(credentials: &str) -> Vec<u8> {
format!(r#"{{"version":"0.1.0","http":{{"credentials":{credentials}}}}}"#).into_bytes()
}
fn entry_named(name: &str) -> IronHubToolEntry {
IronHubToolEntry {
name: name.to_string(),
crate_name: name.replace('-', "_"),
version: "0.1.0".to_string(),
description: "test tool".to_string(),
provenance: IronHubProvenance::Official,
wasm: IronHubArtifact {
url: "https://hub.ironclaw.com/t.wasm".to_string(),
size_bytes: 1,
sha256: "a".repeat(64),
},
capabilities: IronHubArtifact {
url: "https://hub.ironclaw.com/t.json".to_string(),
size_bytes: 1,
sha256: "b".repeat(64),
},
}
}
/// A published credential recipe must reach the generated manifest.
///
/// The catalog artifact already carries `http.credentials`, but
/// `generic_tool_manifest` discarded it and emitted `effects = ["network"]`
/// with no credential block — so every credentialed IronHub tool installed
/// "successfully" and could never authenticate. Attio is the reported case.
#[test]
fn bearer_credentials_reach_the_generated_manifest() {
let entry = entry_named("attio");
let manifest = generic_tool_manifest_with_credentials(
&entry,
"wasm/attio_tool.wasm",
"in.json",
"out.json",
&caps(
r#"{"attio_api_key":{"secret_name":"attio_api_key","location":{"type":"bearer"},"host_patterns":["api.attio.com"]}}"#,
),
)
.expect("bearer credential maps");
assert!(
manifest.contains("use_secret"),
"a tool needing a secret must declare the effect: {manifest}"
);
assert!(manifest.contains("[[tools.credentials]]"), "{manifest}");
assert!(
manifest.contains(r#"handle = "attio_api_key""#),
"{manifest}"
);
assert!(manifest.contains(r#"host = "api.attio.com""#), "{manifest}");
assert!(
manifest.contains(r#"prefix = "Bearer ""#),
"bearer maps to an Authorization header with a Bearer prefix: {manifest}"
);
}
/// monday.com sends the raw token as the Authorization value with NO
/// "Bearer " prefix; emitting one would silently break every request.
#[test]
fn raw_header_credentials_keep_no_prefix() {
let entry = entry_named("monday");
let manifest = generic_tool_manifest_with_credentials(
&entry,
"wasm/monday_tool.wasm",
"in.json",
"out.json",
&caps(
r#"{"monday_api_token":{"secret_name":"monday_api_token","location":{"type":"header","name":"Authorization"},"host_patterns":["api.monday.com"]}}"#,
),
)
.expect("raw header credential maps");
assert!(
manifest.contains(r#"handle = "monday_api_token""#),
"{manifest}"
);
assert!(
!manifest.contains("prefix"),
"monday takes the raw Authorization value; a prefix must not be invented: {manifest}"
);
}
/// v3 injection has no Basic variant. Installing a tool whose credential
/// cannot be injected would repeat the failure this change fixes — a
/// successful install that can never authenticate — so it fails closed.
#[test]
fn unsupported_credential_location_fails_the_install_loudly() {
let entry = entry_named("wazuh");
let error = generic_tool_manifest_with_credentials(
&entry,
"wasm/wazuh_tool.wasm",
"in.json",
"out.json",
&caps(
r#"{"wazuh_indexer_password":{"secret_name":"wazuh_indexer_password","location":{"type":"basic","username":"admin"},"host_patterns":["wazuh-indexer.local"]}}"#,
),
)
.expect_err("an unmappable credential must not install silently");
let text = error.to_string();
assert!(text.contains("wazuh"), "names the tool: {text}");
assert!(
text.contains("basic"),
"names the unsupported shape: {text}"
);
}
/// The generated manifest must satisfy the real v3 parser, not just contain
/// the right substrings — a credential block that fails `parse_manifest_v3`
/// would break activation at install time instead of at authentication time.
#[test]
fn generated_credential_manifest_parses_as_v3() {
let entry = entry_named("attio");
let manifest = generic_tool_manifest_with_credentials(
&entry,
"wasm/attio_tool.wasm",
"schemas/attio/invoke.input.v1.json",
"schemas/attio/raw_output.v1.json",
&caps(
r#"{"attio_api_key":{"secret_name":"attio_api_key","location":{"type":"bearer"},"host_patterns":["api.attio.com"]}}"#,
),
)
.expect("bearer credential maps");
let parsed: toml::Value = toml::from_str(&manifest).expect("manifest is valid TOML");
let credential = &parsed["tools"][0]["credentials"][0];
assert_eq!(credential["handle"].as_str(), Some("attio_api_key"));
assert_eq!(credential["vendor"].as_str(), Some("attio"));
assert_eq!(
credential["audience"]["host"].as_str(),
Some("api.attio.com")
);
assert_eq!(credential["injection"]["type"].as_str(), Some("header"));
assert_eq!(credential["injection"]["prefix"].as_str(), Some("Bearer "));
let effects = parsed["tools"][0]["effects"]
.as_array()
.expect("effects array");
assert!(
effects
.iter()
.any(|effect| effect.as_str() == Some("use_secret")),
"the tool must declare use_secret: {effects:?}"
);
}
/// A tool with no credentials keeps the previous shape exactly.
#[test]
fn credential_free_tools_are_unchanged() {
let entry = entry_named("near-rpc");
let manifest = generic_tool_manifest_with_credentials(
&entry,
"wasm/near_rpc_tool.wasm",
"in.json",
"out.json",
br#"{"version":"0.1.0"}"#,
)
.expect("no credentials is valid");
assert!(!manifest.contains("use_secret"), "{manifest}");
assert!(!manifest.contains("[[tools.credentials]]"), "{manifest}");
assert!(manifest.contains(r#"effects = ["network"]"#), "{manifest}");
}
#[test]
fn generic_tool_manifest_uses_current_v3_extension_contract() {
let entry = IronHubToolEntry {
@@ -104,6 +423,7 @@ mod tests {
"wasm/quote_tool_tool.wasm",
"schemas/quote_tool/invoke.input.v1.json",
"schemas/quote_tool/raw_output.v1.json",
r#"["network"]"#,
);
let parsed: toml::Value = toml::from_str(&manifest).expect("manifest TOML parses");
assert_eq!(