Skip to content

CH 21 · Hook Plugins and Interception: Tampering Before Tool Execution

Word count~2,560 wordsTime~22 minPrereqCH 20 (defineTool)LevelReproducible

Chapter Goal

In CH 20 we let the model call its own tool. This chapter goes further: hang your own logic before and after the tool executes — record who called what, intercept tools that shouldn't be called, decide to allow or deny.

This is "hook plugins". It's the foundation of dsh's permission system, sandbox, audit capabilities, and the most typical embodiment of "everything is a plugin".

Tool Calls Aren't a Straight Line

In CH 11 we mentioned a concept: when the model says to call a tool, it doesn't execute directly, but goes through an extensible pipeline. The official team made it a "guarded pipeline" — every stage can be intercepted and enhanced by plugins.

A tool call goes through these stages:

text
Model wants to call a tool

pre-execute   → Policy gate: allow / deny / ask (permission, sandbox, interception all here)

guard         → Monotonic guard: once denied, subsequent listeners cannot revoke (final defense line)

execute       → Actually execute the tool

post-execute  → Result transformation: rewrite return value, append content

result        → Read-only observation: glance at the result, can't change

Result returns to the model

"Hooks" are plugins mounted on a certain stage: use ctx.on('tools/xxx', ...) to subscribe to the corresponding event, and do what you want in the event.

The official docs have a clear table on what each extension point can do:

Extension pointEffectTypical usage
tools/pre-executeDecision layer before tool executionAllow / deny / ask, permission gate
ctx.tools.guard()Monotonic final denialHard limit that cannot be revoked by subsequent listeners
tools/executeWraps the entire dispatch cycleAdd timeout, retry, metric collection
tools/post-executeExplicitly transform the resultReplace displayed content, append model-visible context
tools/resultRead-only observation of immutable resultAudit logs, statistics, cannot change

pre-execute is a waterfall event: your listener can return next() (allow) or { kind: 'deny', reason: '...' } (deny).

Hands-on: Write an "Audit + Interception" Hook Plugin

We'll write a hook plugin that both records every tool call and denies specified tools. The deny list is made configurable — practicing the "plugin can be configured" capability at the same time.

Step 1: Create Directory, Install Dependencies

In a workspace where you want to put plugins:

powershell
New-Item -ItemType Directory -Path "hook-demo\src" -Force
cd "E:\software-workspace\DeepSeek harness demo\hook-demo"   # replace with your directory
npm init -y
npm install @deepseek-ai/schemastery

schemastery is the library for defining config schemas (when a plugin needs to be configurable, it uses this to declare the shape and defaults of the config). Remember to add "type": "module" in package.json.

Step 2: Write the Hook Plugin

Create hook-demo\src\audit.js:

js
import Schema from '@deepseek-ai/schemastery'

export const name = 'audit-hook'

export const Config = Schema.object({
  denyTools: Schema.array(Schema.string()).default([]),
})

export function apply(ctx, config) {
  ctx.on('tools/pre-execute', (exec, next) => {
    console.log(`[audit] Tool will be called: ${exec.name}`)
    if (config.denyTools.includes(exec.name)) {
      return { kind: 'deny', reason: `Policy: this session is forbidden from calling ${exec.name}` }
    }
    return next()
  })
}

Line by line breakdown:

  • Config: declares the plugin's configurable items. denyTools is an array of strings, defaulting to an empty array. The config in apply(ctx, config) is the result of merging user config and defaults.
  • ctx.on('tools/pre-execute', ...): subscribe to the before-tool-execution event. Every time a tool is about to be called, it goes through here.
  • console.log: audit — log that this tool is about to be called.
  • config.denyTools.includes(exec.name): if this tool is in the deny list, return { kind: 'deny', reason } to intercept it.
  • return next(): otherwise allow it, let the pipeline continue.

Step 3: Pass Config in cordis.yml

Create hook-demo\cordis.yml (replace the path with your own, remember %20 for spaces):

yaml
- insert:
    - id: audit
      name: 'file:///E:/your-workspace/hook-demo/src/audit.js'
      config:
        denyTools: ['pwsh']

Here config adds pwsh (PowerShell) to the deny list. Note: the plugin code didn't change a single character, but the behavior changed — that's exactly what config is for, and the official design principle of "no hard-coded adjustable parameters": values that can be changed in cordis.yml shouldn't be hardcoded in the code.

Step 4: Run It and See the Effect

Use headless to run once, let the model call pwsh:

powershell
cd your-workspace-directory
dsh --profile headless --patch "./hook-demo/cordis.yml" "Use pwsh to run Get-ChildItem to list the current directory"

My actual terminal output:

Two yellow [audit] logs are our hook recording: the model first called skill, then pwsh — every tool call went through our hook. And pwsh is in the deny list, so it was deny'd, the model perceived the denial, proactively reported "this session is forbidden from calling pwsh", and offered an alternative.

One plugin, doing both audit (visible) and interception (controllable). That's the power of hooks.

Let dsh Do It: One Prompt Does It

It's faster to have dsh write this plugin. Send directly in the Web UI's input box:

text
In my current workspace, help me write a hook plugin: print a log line before a tool is called, and be able to deny specified tools via configuration. Implement per the official spec, run a headless to verify it can record and intercept, and finally tell me the result.

It will go read the official docs itself, write the plugin, verify both record and intercept work. You just verify.

This is the result of me actually running this prompt: it first laid out a version of the implementation points itself — plugins can only have named exports of name / inject / apply, inject: ['tools'] ensures the tool registry is ready, use ctx.on('tools/pre-execute', ...) to hang the hook (consistent with the official permission-gate example), return { kind: 'deny', reason } to deny, await next() to allow — even "deny list goes through config, no code change" was thought of for you.

This trajectory diagram is its complete work process: write to write the plugin file, pwsh to run its own verification script (8 checks all pass), todo_write to update the task list... every one is a tool call.

Common Pitfalls

ProblemWhat's happeningHow to handle
Forgot return next()Waterfall event without next, the pipeline stallspre-execute / post-execute must return next() or a decision
Model keeps retrying after denyModel doesn't know this tool is permanently unavailableWrite the reason clearly, the model will see it and switch to a different approach
Config didn't take effectconfig in cordis.yml is wrongCheck field name and schema type match
Reports Cannot find package '@deepseek-ai/schemastery'Plugin directory didn't install dependenciesnpm install @deepseek-ai/schemastery

What you learned in this chapter

  • [ ] State the main stages of the tool call pipeline (pre-execute / execute / post-execute / result)
  • [ ] Use ctx.on('tools/pre-execute', ...) to write a hook plugin
  • [ ] Return { kind: 'deny', reason } to intercept a tool, next() to allow
  • [ ] Use Config + Schemastery to make the plugin configurable (deny list)
  • [ ] State the design principle of "no hard-coded adjustable parameters"

Open Source · MIT · Community Driven