Interceptors
Edit on GitHubAn interceptor is an extension's answer at one of five points in the loop: it can refuse a tool call, change its input or its result, shape the context, filter what memory returns, or change the request's options. An extension that only wants to watch uses the bus instead, which cannot change anything.
Nothing changes what the model is sent except through the context builder, which records its edits (see Context, compaction and memory).
The five points
| Point | It may | It is given |
|---|---|---|
tool.before | Deny, rewrite the input, or answer the call itself (handled). | { tool, input } |
tool.after | Rewrite the result. | { tool, input, outcome } |
context.build | Add edits to the pass. | { view, edits } |
memory.recall | Filter or redact what recall returned. | The entries |
provider.before | Rewrite the request's options only: output ceiling, effort, cache hints, attachments to leave out. Never the messages or the tools. | { max_output_tokens?, effort?, cache?, omit_attachments? } |
Define one
// TypeScript
export default defineExtension({
interceptors: {
"tool.before": {
name: "no-force-push",
mode: "gate", // "gate" allows or denies; "rewrite" may change the payload
tools: ["builtin.bash"], // the host asks only for these calls; none: every tool
agents: ["main"], // optional: `main`, or an agent persona glob
run: ({ input }) => (/push .*--force/.test(input.command) ? { deny: "push a new branch instead" } : null),
},
},
});// Rust, in the extension's `register`, on the ExtCtx
use orrery_ext_api::intercept::{InterceptFilter, InterceptMode};
ctx.interceptor(
"no-force-push",
"tool.before",
InterceptMode::Gate,
InterceptFilter { tools: vec!["builtin.bash".into()], agents: vec![] },
Arc::new(NoForcePush), // impl InterceptorHandler: async fn run(&self, at, payload) -> VerdictWire
)?;An interceptor's answer (TypeScript):
| Return | Means |
|---|---|
nothing, null or "continue" | Carry on. |
{ deny: "reason" } | Refuse. The reason is shown to the model and the person. |
{ rewrite: payload } | Change the payload. The same shape it was given. |
{ handled: value } | tool.before only: the call is answered with this, and does not run. |
A rewrite that changes a result, for example to redact a token from tool.after:
interceptors: {
"tool.after": {
name: "redact-tokens",
mode: "rewrite",
tools: ["builtin.bash"],
run: ({ tool, input, outcome }) => {
if (outcome.t !== "ok" || typeof outcome.value !== "string") return null;
const value = outcome.value.replace(/ghp_[A-Za-z0-9]+/g, "[redacted]");
return value === outcome.value ? null : { rewrite: { tool, input, outcome: { ...outcome, value } } };
},
},
},Rules as data
A rule the host applies with no call at all:
rules: [
{ t: "refuse", tools: ["builtin.write"], reason: "read-only session" },
{ t: "default", tool: "builtin.bash", pointer: "/timeout", value: 60000 },
],refuse denies the listed tools. default fills an input field when the call leaves it out. Use a rule when no code is needed; a rule is cheaper because the host never asks the process.
How several compose
When more than one interceptor answers a point, the answers combine by mode:
- Rewrites chain, in layer order (closest first). Each sees the payload the previous one returned. A deny anywhere in the chain stops it at once.
- Gates run in parallel after the chain, on the payload it left. Any deny wins; with several, the first by layer order gives the reason.
- Core's policy check comes after
tool.before. A rewritten input is checked like any other: no interceptor can rewrite its way past policy. - Watchers (the bus) are never awaited and cannot change anything.
Every deny, rewrite and handled answer is in the audit.
Deadlines and failure
An interceptor from a process is async and has a deadline. The defaults and the keys that change them are in Config keys:
| Point | One answer | Every interceptor on the point |
|---|---|---|
tool.before | 1000 ms | 3 × the single deadline |
tool.after | 2000 ms | 3 × the single deadline |
context.build | 2000 ms | 3 × the single deadline |
memory.recall | 2000 ms | 3 × the single deadline |
provider.before | 1000 ms | 3 × the single deadline |
The point also caps how many interceptors it runs (limits.interceptorsPerPhase, 8), and an extension caps requests in flight (limits.inFlight, 16).
When an answer does not come, the mode decides:
- A gate fails closed. No answer in time, a crash, or an answer that does not parse is a denial that names the extension.
- A rewrite that can deny fails closed for the same reasons.
- A rewrite-only interceptor fails open. It is skipped, and the log names it.
Permission
Seeing or changing calls is a privilege. An extension declares what it intercepts in its manifest, scoped by point and tool:
[requires]
intercept = ["tool.before:builtin.bash"]The user sees it at install. Without intercept under [requires], the host registers none of the extension's interceptors or intercept rules: each is reported as a load problem naming it and the fix, and the rest of the extension loads.
Under orrery run, a call a gate or rule denied makes the run exit 4, denied by policy (exit codes).
provider.before changes options, not the conversation. To change what the model is sent, use the context builder.