Module 9: Write your own plugin#
You are in the CPEX tutorial. Runs without the IdP.
Goal: write a plugin for a check the builtins do not ship, register it, and reference it from policy by name, exactly like a builtin.
The problem#
The bundled plugins cover common needs, but your domain has its own rules. You need a way to drop custom logic into the pipeline without forking CPEX, and to wire it from policy the same way you wire a builtin.
Build it#
A plugin is a Rust type implementing a hook handler. This one, a business-hours guard, reads the request’s hour argument and its own open/close config, and denies calls outside the window. From examples/m09_custom_plugin.rs:
impl HookHandler<CmfHook> for BusinessHours {
async fn handle(&self, payload: &MessagePayload, _ext: &Extensions, _ctx: &mut PluginContext)
-> PluginResult<MessagePayload>
{
let hour = payload.message.get_tool_calls().into_iter().next()
.and_then(|tc| tc.arguments.get("hour")).and_then(|v| v.as_u64());
match hour {
Some(h) if h >= self.open_hour && h < self.close_hour => PluginResult::allow(),
Some(h) => PluginResult::deny(PluginViolation::new("office.closed",
format!("requested at hour {h}, outside {}-{}", self.open_hour, self.close_hour))),
None => PluginResult::deny(PluginViolation::new("office.no_hour", "no `hour` argument")),
}
}
}A factory builds it from config and wires the handler onto a hook. You register the factory before loading the policy, then reference the plugin by name. The traits come from cpex-sdk; the factory and handler-adapter plumbing come from cpex-core, the same pattern every builtin uses.
mgr.register_factory("business-hours", Box::new(BusinessHoursFactory));
cpex::install_builtins(&mgr);
mgr.load_config_yaml(POLICY).unwrap();The policy references it like any builtin (policies/m09.yaml):
plugins:
- name: business-hours
kind: business-hours
hooks: [cmf.tool_pre_invoke]
config: { open_hour: 9, close_hour: 17 }
routes:
- tool: get_compensation
authorization:
pre_invocation:
- "run(business-hours)"Run it#
cargo run -p cpex-tutorial --example m09_custom_plugin▸ get_compensation at 10:00 (within 9-17 window)
✓ ALLOWED { ... }
▸ get_compensation at 22:00 (outside the window)
✗ DENIED [office.closed] requested at hour 22, outside business hours 9-17Try it#
- Change the window. In
examples/tutorial/policies/m09.yaml, setclose_hour: 23and re-run. Expect: the 22:00 call now allows. Config feeds the plugin. - Change the denial. In
examples/tutorial/examples/m09_custom_plugin.rs, edit thePluginViolation::new("office.closed", ...)call in theSome(h)arm, changing the code and reason strings, and re-run at hour 22. Expect: the denial line shows your new code and reason. (The tutorial’sOutcomesurfaces the code and reason; a plugin can also attachdescriptionanddetailson the violation, which a real host logs even though this harness does not print them.) - Gate a different tool. In
policies/m09.yaml, add a second route so the same plugin guards it:Then add a call to it in- tool: search_repos authorization: pre_invocation: - "run(business-hours)"m09_custom_plugin.rs(e.g.mediate(&mgr, &caller, "search_repos", json!({ "visibility": "public", "hour": 22 }), backends::search_repos)), re-run, and confirmsearch_reposis denied at hour 22 too. One plugin, many routes.
Checkpoint#
How does policy find your plugin?
What decides allow vs. deny?
Go deeper#
Next#
Module 10: Testing your policy: write table-driven allow/deny tests that run in CI.