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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,21 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).

### Fixed

- **Delegators and elicitation handlers are no longer reported as narrowed when
their family-specific hooks are reached.** `delegate(...)` is credited to
`token.delegate`, and elicitation verbs to `elicit`. Unreached hooks declared by
those plugins are still reported.

- **The bundled hyper transport selects `ring` explicitly.** It no longer depends
on rustls's process default, which is ambiguous when a host includes both `ring`
and `aws-lc-rs`. It neither reads nor installs that default, and TLS setup errors
now return `HttpTransportError::Connect` instead of panicking.

- **Glob routes under `tool:`, `resource:`, `prompt:`, and `llm:` now evaluate
their policy bodies.** Annotation lookup now resolves a request name to the
configured pattern. Exact selectors still outrank globs, including when the
exact route has no policy body.

- **A bundle joined through both `meta.tags` and `groups:` no longer runs its
`authentication:` steps twice.** Bundle membership is now deduplicated before
authentication and assertion layers are resolved, keeping their inheritance
Expand Down
6 changes: 4 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions crates/ppe-apl-runtime/src/dispatch_plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,29 @@ pub(crate) fn collect_plugin_names_by_half(route: &CompiledRoute) -> (Vec<String
(pre, post)
}

/// Return `(plugin, hook)` pairs for effects whose family fixes the hook.
///
/// Delegation uses `token.delegate`; elicitation uses `elicit`, independent of
/// the route's entity.
pub(crate) fn collect_family_fixed_plugin_hooks(
route: &CompiledRoute,
) -> Vec<(String, &'static str)> {
let mut out: Vec<(String, &'static str)> = Vec::new();
let mut visit = |e: &Effect| {
let pair = match e {
Effect::Delegate(ds) => (ds.plugin_name.clone(), HOOK_TOKEN_DELEGATE),
Effect::Elicit(es) => (es.plugin_name.clone(), HOOK_ELICIT),
_ => return,
};
if !out.contains(&pair) {
out.push(pair);
}
};
walk_effects(&route.pre_invocation, &mut visit);
walk_effects(&route.post_invocation, &mut visit);
out
}

/// Compute the union of capabilities declared by every plugin a
/// `CompiledRoute` can dispatch to (with per-route overrides applied).
///
Expand Down
41 changes: 26 additions & 15 deletions crates/ppe-apl-runtime/src/visitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -518,20 +518,35 @@ impl AplConfigVisitor {
/// routes below it.
fn record_reached_plugins(&self, route: &CompiledRoute, hook_pre: &str, hook_post: &str) {
let (pre, post) = crate::dispatch_plan::collect_plugin_names_by_half(route);
// Delegation and elicitation use family-specific hooks, recorded below.
let family_fixed = crate::dispatch_plan::collect_family_fixed_plugin_hooks(route);
let mut state = self
.state
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let family_fixed_names: std::collections::HashSet<&str> =
family_fixed.iter().map(|(name, _)| name.as_str()).collect();
for (names, hook) in [(pre, hook_pre), (post, hook_post)] {
for name in names {
state.reached_plugin_names.insert(name.clone());
if family_fixed_names.contains(name.as_str()) {
continue;
}
state
.reached_plugin_hooks
.entry(name)
.or_default()
.insert(hook.to_owned());
}
}
for (name, hook) in family_fixed {
state.reached_plugin_names.insert(name.clone());
state
.reached_plugin_hooks
.entry(name)
.or_default()
.insert(hook.to_owned());
}
}

/// Tally the plugins a layer's steps name, without a hook.
Expand Down Expand Up @@ -2145,9 +2160,7 @@ routes:
);
}

/// A glob route under one of the four MCP selectors. The annotation is
/// installed under the pattern as written, and the lookup is exact
/// equality, so a request named by a glob never reaches the body.
/// A glob route's annotation is keyed by its pattern, not the request name.
const GLOB_TOOL_ROUTE: &str = r#"
engine_settings:
dispatch: policy
Expand All @@ -2159,10 +2172,10 @@ routes:
"#;

#[tokio::test]
async fn a_glob_tool_route_still_does_not_evaluate_its_policy_body() {
async fn a_glob_tool_route_evaluates_its_policy_body() {
let mgr = engine_with(GLOB_TOOL_ROUTE).await;

let (allowed, _bg) = mgr
let (denied, _bg) = mgr
.invoke_named::<CmfHook>(
HOOK_CMF_TOOL_PRE_INVOKE,
payload(),
Expand All @@ -2171,25 +2184,23 @@ routes:
)
.await;
assert!(
allowed.continue_processing,
"a name the glob matches does not equal the pattern the handler is \
installed under, so the body does not evaluate; violation = {:?}",
allowed.violation
!denied.continue_processing,
"the route denies and the name the glob covers is governed by it"
);

// The handler exists and its body denies, so the line above is the
// lookup and not a missing installation.
let (denied, _bg) = mgr
// A name outside the pattern reaches no route body.
let (allowed, _bg) = mgr
.invoke_named::<CmfHook>(
HOOK_CMF_TOOL_PRE_INVOKE,
payload(),
tool_request("hr-*"),
tool_request("finance-close"),
None,
)
.await;
assert!(
!denied.continue_processing,
"the body is installed under the pattern as written"
allowed.continue_processing,
"a name outside the pattern reaches no body; violation = {:?}",
allowed.violation
);
}

Expand Down
67 changes: 67 additions & 0 deletions crates/ppe-apl-runtime/tests/dispatch_mode_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,73 @@ routes:
);
}

/// A `delegate(...)` step reaches its plugin on `token.delegate`.
#[test]
fn a_delegator_reached_by_a_delegate_step_is_not_reported_as_narrowed() {
let alarms = alarms_raised_by_loading(
"
plugins:
- name: workday-oauth
kind: builtin
hooks: [token.delegate]
routes:
- tool: get_compensation
authorization:
pre_invocation:
- \"delegate(workday-oauth, target: workday-api, audience: workday-api)\"
",
);
assert!(
!alarms.contains(&NARROWED.to_owned()),
"`token.delegate` is covered: {alarms:?}"
);
}

/// An elicitation verb reaches its handler on `elicit`.
#[test]
fn an_elicitation_handler_reached_by_a_verb_is_not_reported_as_narrowed() {
let alarms = alarms_raised_by_loading(
"
plugins:
- name: manager-approver
kind: builtin
hooks: [elicit]
routes:
- tool: adjust_compensation
authorization:
pre_invocation:
- \"require_approval(manager-approver, from: claim.manager, channel: \\\"ciba\\\")\"
",
);
assert!(
!alarms.contains(&NARROWED.to_owned()),
"`elicit` is covered: {alarms:?}"
);
}

/// Family-specific reachability does not hide other uncovered hooks.
#[test]
fn a_delegator_declaring_an_unreached_cmf_hook_is_still_reported() {
let alarms = alarms_raised_by_loading(
"
plugins:
- name: workday-oauth
kind: builtin
hooks: [token.delegate, cmf.tool_post_invoke]
routes:
- tool: get_compensation
authorization:
pre_invocation:
- \"delegate(workday-oauth, target: workday-api, audience: workday-api)\"
",
);
assert!(
alarms.contains(&NARROWED.to_owned()),
"`cmf.tool_post_invoke` is declared and no step reaches it there: \
{alarms:?}"
);
}

// ---- the core-side backstop -------------------------------------------

/// A host that registers no orchestrator gets the flipped default with no
Expand Down
Loading