Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ Trace Commons issuer/TenantCtx note: the server-side `zmanian/tracedao-server` s
| Tool-level streaming | ✅ | ❌ | |
| Z.AI tool_stream | ✅ | ❌ | Real-time tool call streaming |
| Plugin tools | ✅ | ✅ | WASM tools |
| GSuite WASM tools | ✅ | 🚧 | Reborn bundles operation-level Google Drive/Docs/Sheets/Slides WASM packages with host-mediated HTTP egress and product-auth scoped bearer injection; live OAuth/setup UX and full live-recorded parity remain follow-up |
| Hosted MCP extensions | ✅ | 🚧 | Reborn composes host-mediated MCP runtime and bundles the current Notion MCP supported tool set; Notion ProductAuth OAuth exchange/refresh backend is wired, while dynamic schema discovery and user-start/DCR OAuth setup parity remain pending |
| NEAR AI MCP extension | ✅ | 🚧 | Host-bundled Reborn MCP extension exposes `nearai.search` via host-mediated HTTP and `llm_nearai_api_key`; this is a static NEAR adapter, while the generic product-auth-to-MCP staged credential bridge remains tracked by #4176 and dynamic MCP tool discovery remains pending |
| Tool policies (allow/deny) | ✅ | ✅ | |
Expand Down
43 changes: 43 additions & 0 deletions crates/ironclaw_auth/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ pub const GOOGLE_CALENDAR_READONLY_SCOPE: &str =
"https://www.googleapis.com/auth/calendar.readonly";
/// Read/write access to Google Calendar events.
pub const GOOGLE_CALENDAR_EVENTS_SCOPE: &str = "https://www.googleapis.com/auth/calendar.events";
/// Read-only access to Google Drive files.
pub const GOOGLE_DRIVE_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/drive.readonly";
/// Read/write access to Google Drive files.
pub const GOOGLE_DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive";
/// Read-only access to Google Docs documents.
pub const GOOGLE_DOCS_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/documents.readonly";
/// Read/write access to Google Docs documents.
pub const GOOGLE_DOCS_SCOPE: &str = "https://www.googleapis.com/auth/documents";
/// Read-only access to Google Sheets spreadsheets.
pub const GOOGLE_SHEETS_READONLY_SCOPE: &str =
"https://www.googleapis.com/auth/spreadsheets.readonly";
/// Read/write access to Google Sheets spreadsheets.
pub const GOOGLE_SHEETS_SCOPE: &str = "https://www.googleapis.com/auth/spreadsheets";
/// Read-only access to Google Slides presentations.
pub const GOOGLE_SLIDES_READONLY_SCOPE: &str =
"https://www.googleapis.com/auth/presentations.readonly";
/// Read/write access to Google Slides presentations.
pub const GOOGLE_SLIDES_SCOPE: &str = "https://www.googleapis.com/auth/presentations";
/// Read-only access to Gmail messages and metadata.
pub const GOOGLE_GMAIL_READONLY_SCOPE: &str = "https://www.googleapis.com/auth/gmail.readonly";
/// Permission to send Gmail messages.
Expand Down Expand Up @@ -586,6 +604,14 @@ pub fn is_allowed_google_scope(scope: &str) -> bool {
scope,
GOOGLE_CALENDAR_READONLY_SCOPE
| GOOGLE_CALENDAR_EVENTS_SCOPE
| GOOGLE_DRIVE_READONLY_SCOPE
| GOOGLE_DRIVE_SCOPE
| GOOGLE_DOCS_READONLY_SCOPE
| GOOGLE_DOCS_SCOPE
| GOOGLE_SHEETS_READONLY_SCOPE
| GOOGLE_SHEETS_SCOPE
| GOOGLE_SLIDES_READONLY_SCOPE
| GOOGLE_SLIDES_SCOPE
| GOOGLE_GMAIL_READONLY_SCOPE
| GOOGLE_GMAIL_SEND_SCOPE
| GOOGLE_GMAIL_MODIFY_SCOPE
Expand Down Expand Up @@ -690,4 +716,21 @@ mod tests {
assert!(OAuthRedirectUri::new("http://localhost:8080/callback").is_ok());
assert!(OAuthRedirectUri::new("http://127.0.0.1:8080/callback").is_ok());
}

#[test]
fn google_oauth_allowlist_includes_gsuite_wasm_scopes() {
for scope in [
GOOGLE_DRIVE_READONLY_SCOPE,
GOOGLE_DRIVE_SCOPE,
GOOGLE_DOCS_READONLY_SCOPE,
GOOGLE_DOCS_SCOPE,
GOOGLE_SHEETS_READONLY_SCOPE,
GOOGLE_SHEETS_SCOPE,
GOOGLE_SLIDES_READONLY_SCOPE,
GOOGLE_SLIDES_SCOPE,
] {
assert!(is_allowed_google_scope(scope), "{scope} must be allowed");
assert!(parse_google_requested_scopes(&[scope.to_string()]).is_ok());
}
}
}
1 change: 1 addition & 0 deletions crates/ironclaw_authorization/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1178,6 +1178,7 @@ fn obligations_for_grant(
obligations.push(Obligation::InjectCredentialAccountOnce {
handle: credential.handle.clone(),
provider: provider.clone(),
provider_scopes: credential.provider_scopes.clone(),
requester_extension: descriptor.provider.clone(),
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ async fn capability_access_resolves_product_auth_account_runtime_credentials() {
source: RuntimeCredentialRequirementSource::ProductAuthAccount {
provider: RuntimeCredentialAccountProviderId::new("github").unwrap(),
},
provider_scopes: vec!["repo".to_string()],
..runtime_credential(slot.clone(), github_audience(), true)
}],
..wasm_descriptor()
Expand Down Expand Up @@ -209,6 +210,7 @@ async fn capability_access_resolves_product_auth_account_runtime_credentials() {
&[Obligation::InjectCredentialAccountOnce {
handle: slot,
provider: RuntimeCredentialAccountProviderId::new("github").unwrap(),
provider_scopes: vec!["repo".to_string()],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium · 55% confidence] No positive / over-scope test for scope-based account selection (the PR's stated priority)

tests reviewer

This PR's entire purpose is to thread provider_scopes from the manifest into runtime-credential selection so product-auth picks a least-privilege Google account. The propagation chain is well covered by new tests: the manifest validator (v2::tests::validate_runtime_credential_provider_scopes_* and manifest_v2_contract), the obligation emission (runtime_credentials_contract::capability_access_resolves_product_auth_account_runtime_credentials, asserting provider_scopes: vec!["repo"]), and the host obligation handler (builtin_obligation_handler_contract.rs:1333-1364, asserting a non-empty drive.readonly scope reaches credential_requirements[0].provider_scopes).

The actual selection DECISION that those scopes drive is account_has_provider_scopes in crates/ironclaw_reborn_composition/src/product_auth_runtime_credentials.rs:170 (required.all(|r| account.scopes.any(|s| s == r))), consumed by ProductAuthRuntimeCredentialResolver::resolve_access_secret. Of the 11 resolver tests in that module's mod tests, exactly one passes a non-empty provider_scopes (resolver_requires_requested_provider_scopes, line 482), and it asserts only the NEGATIVE branch: account holds gmail.send, request requires drive, result AuthRequired. Every test that asserts a successful Ok(secret) selection (lines 285/327/370/413/701) passes provider_scopes: &[].

Result: the positive branch of account_has_provider_scopes is never exercised end-to-end, and the over-scoped case the PR explicitly calls out ("can it select an over-scoped/wrong account") has zero coverage. A regression inverting the filter (e.g. !account.scopes.contains, or swapping the subset/superset direction so a documents.readonly-only account satisfies a documents write requirement) would still pass every existing test, because no positive selection ever supplies a required scope. This is the same caller-level coverage class the repo's testing.md "Test Through the Caller" rule mandates for a predicate that gates a secret read. (Selection module is pre-existing, not edited by this PR; anchored here on the in-scope obligation test this PR modified.)

Fix: Add tests::product_auth_runtime_credentials::resolver_selects_account_whose_scopes_superset_required covering the positive/over-scope path: create one Configured UserReusable google account whose scopes are a strict superset of the request (e.g. account has [.../drive, .../gmail.send], request provider_scopes = [".../drive"]) and assert resolve_access_secret returns Ok(access_secret); plus tests::product_auth_runtime_credentials::resolver_requires_exact_scope_string_not_readonly_variant asserting a documents-only account does NOT satisfy a documents.readonly requirement (and vice versa), pinning the exact-string match semantics. Both belong in the mod tests of crates/ironclaw_reborn_composition/src/product_auth_runtime_credentials.rs.

#[tokio::test]
async fn resolver_selects_account_whose_scopes_superset_required() {
    // account.scopes = [drive, gmail.send]; request requires only [drive] -> Ok(secret)
    // guards account_has_provider_scopes positive/over-scope branch
}

requester_extension: ExtensionId::new("echo").unwrap(),
}]
);
Expand Down Expand Up @@ -258,6 +260,7 @@ fn runtime_credential(
RuntimeCredentialRequirement {
handle,
source: Default::default(),
provider_scopes: Vec::new(),
audience,
target: RuntimeCredentialTarget::Header {
name: "authorization".to_string(),
Expand Down
101 changes: 101 additions & 0 deletions crates/ironclaw_extensions/src/v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -869,9 +869,15 @@ impl CapabilityDeclV2 {
),
})?;
validate_runtime_credential_audience(&id, &raw_credential.audience)?;
let provider_scopes = validate_runtime_credential_provider_scopes(
&id,
&raw_credential.source,
raw_credential.provider_scopes,
)?;
runtime_credentials.push(RuntimeCredentialRequirement {
handle,
source: raw_credential.source,
provider_scopes,
audience: raw_credential.audience,
target: raw_credential.target,
required: raw_credential.required,
Expand Down Expand Up @@ -1431,6 +1437,8 @@ struct RawRuntimeCredentialV2 {
handle: String,
#[serde(default)]
source: RuntimeCredentialRequirementSource,
#[serde(default)]
provider_scopes: Vec<String>,
audience: NetworkTargetPattern,
target: RuntimeCredentialTarget,
#[serde(default = "default_runtime_credential_required")]
Expand All @@ -1440,3 +1448,96 @@ struct RawRuntimeCredentialV2 {
fn default_runtime_credential_required() -> bool {
true
}

fn validate_runtime_credential_provider_scopes(
Comment thread
serrrfirat marked this conversation as resolved.
Comment thread
serrrfirat marked this conversation as resolved.
capability_id: &CapabilityId,
source: &RuntimeCredentialRequirementSource,
raw_scopes: Vec<String>,
) -> Result<Vec<String>, ManifestV2Error> {
if !raw_scopes.is_empty()
&& !matches!(
source,
RuntimeCredentialRequirementSource::ProductAuthAccount { .. }
)
{
return Err(ManifestV2Error::Invalid {
reason: format!(
"capability {capability_id} declares runtime credential provider scopes for a non product-auth credential source"
),
});
}
let mut seen = BTreeSet::new();
let mut scopes = Vec::with_capacity(raw_scopes.len());
for raw_scope in raw_scopes {
if raw_scope.trim() != raw_scope || raw_scope.is_empty() {
return Err(ManifestV2Error::Invalid {
reason: format!(
"capability {capability_id} declares invalid runtime credential provider scope"
),
});
}
if !seen.insert(raw_scope.clone()) {
return Err(ManifestV2Error::Invalid {
reason: format!(
"capability {capability_id} declares duplicate runtime credential provider scope {raw_scope}"
),
});
}
scopes.push(raw_scope);
}
Ok(scopes)
}

#[cfg(test)]
mod tests {
use super::*;

fn capability_id() -> CapabilityId {
CapabilityId::new("acme.echo").unwrap()
}

fn product_auth_source() -> RuntimeCredentialRequirementSource {
RuntimeCredentialRequirementSource::ProductAuthAccount {
provider: ironclaw_host_api::RuntimeCredentialAccountProviderId::new("google").unwrap(),
}
}

#[test]
fn validate_runtime_credential_provider_scopes_rejects_empty_scope() {
let err = validate_runtime_credential_provider_scopes(
&capability_id(),
&product_auth_source(),
vec!["".to_string()],
)
.unwrap_err();

assert!(matches!(err, ManifestV2Error::Invalid { .. }), "{err:?}");
}

#[test]
fn validate_runtime_credential_provider_scopes_rejects_whitespace_padded_scope() {
let err = validate_runtime_credential_provider_scopes(
&capability_id(),
&product_auth_source(),
vec![" https://www.googleapis.com/auth/drive".to_string()],
)
.unwrap_err();

assert!(matches!(err, ManifestV2Error::Invalid { .. }), "{err:?}");
}

#[test]
fn validate_runtime_credential_provider_scopes_rejects_duplicate_scope() {
let err = validate_runtime_credential_provider_scopes(
&capability_id(),
&product_auth_source(),
vec![
"https://www.googleapis.com/auth/drive".to_string(),
"https://www.googleapis.com/auth/drive".to_string(),
],
)
.unwrap_err();

assert!(matches!(err, ManifestV2Error::Invalid { .. }), "{err:?}");
}
}
68 changes: 68 additions & 0 deletions crates/ironclaw_extensions/tests/manifest_v2_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,74 @@ default_permission = "allow""#,
);
}

#[test]
fn parses_product_auth_account_runtime_credential_provider_scopes() {
let toml = third_party_wasm_manifest("acme-tools", "acme-tools.echo").replace(
r#"default_permission = "allow""#,
r#"effects = ["network", "use_secret"]
runtime_credentials = [
{ handle = "google_runtime_token", source = { type = "product_auth_account", provider = "google" }, provider_scopes = ["https://www.googleapis.com/auth/drive.readonly"], audience = { scheme = "https", host_pattern = "www.googleapis.com" }, target = { type = "header", name = "authorization", prefix = "Bearer " } },
]
default_permission = "allow""#,
);
let manifest =
ExtensionManifestV2::parse(&toml, ManifestSource::InstalledLocal, &catalog()).unwrap();

assert_eq!(
manifest.capabilities[0].runtime_credentials[0].provider_scopes,
vec!["https://www.googleapis.com/auth/drive.readonly".to_string()]
);
}

#[test]
fn rejects_invalid_runtime_credential_provider_scopes() {
for provider_scopes in [
r#"["https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/drive"]"#,
r#"[""]"#,
r#"[" https://www.googleapis.com/auth/drive"]"#,
r#"["https://www.googleapis.com/auth/drive "]"#,
] {
let toml = third_party_wasm_manifest("acme-tools", "acme-tools.echo").replace(
r#"default_permission = "allow""#,
&format!(
r#"effects = ["network", "use_secret"]
runtime_credentials = [
{{ handle = "google_runtime_token", source = {{ type = "product_auth_account", provider = "google" }}, provider_scopes = {provider_scopes}, audience = {{ scheme = "https", host_pattern = "www.googleapis.com" }}, target = {{ type = "header", name = "authorization", prefix = "Bearer " }} }},
]
default_permission = "allow""#
),
);

let err = ExtensionManifestV2::parse(&toml, ManifestSource::InstalledLocal, &catalog())
.unwrap_err();
assert!(matches!(err, ManifestV2Error::Invalid { .. }), "{err:?}");
assert!(
err.to_string().contains("provider scope"),
"expected provider scope validation error, got {err:?}"
);
}
}

#[test]
fn rejects_provider_scopes_for_non_product_auth_runtime_credentials() {
let toml = third_party_wasm_manifest("acme-tools", "acme-tools.echo").replace(
r#"default_permission = "allow""#,
r#"effects = ["network", "use_secret"]
runtime_credentials = [
{ handle = "api_token", provider_scopes = ["https://www.googleapis.com/auth/drive"], audience = { scheme = "https", host_pattern = "api.example.com" }, target = { type = "header", name = "authorization" } },
]
default_permission = "allow""#,
);
let err =
ExtensionManifestV2::parse(&toml, ManifestSource::InstalledLocal, &catalog()).unwrap_err();

assert!(matches!(err, ManifestV2Error::Invalid { .. }), "{err:?}");
assert!(
err.to_string().contains("non product-auth"),
"expected non product-auth provider scope rejection, got {err:?}"
);
}

#[test]
fn rejects_runtime_credentials_without_use_secret_effect() {
let toml = third_party_wasm_manifest("acme-tools", "acme-tools.echo").replace(
Expand Down
Loading
Loading