dsh-repeat-tool-breaker
Hard break on repeated tool calls: a synchronous ctx.tools.guard gate that counts semantic fingerprints (net/site/sink/cmd/exact) over a per-agent sliding window, ignoring decoy arguments such as description and timeoutMs.
安装
dsh plugin --profile web add github:snailium/dsh-repeat-tool-breaker
需要可复现安装时,可在仓库后追加 #commit 固定提交。
Hard break on repeated tool calls: a synchronous ctx.tools.guard gate that counts semantic fingerprints (net/site/sink/cmd/exact) over a per-agent sliding window, ignoring decoy arguments such as description and timeoutMs.
该插件未提供要点说明,请参考仓库 README。
- 安装并启动 DeepSeek Harness:
npx @deepseek-ai/dsh web - 在终端执行上面的安装命令(CLI 会解析插件并核验来源)
- 用 dsh plugins list 确认已安装,必要时重启 Harness 生效
插件以当前 dsh 进程的权限运行,安装时可能执行代码。请先通读仓库源码与许可证,确认无破坏性命令与越权访问;本站只做索引,不对第三方插件安全性作担保。
| 代码仓库 | github.com/snailium/dsh-repeat-tool-breaker |
| 许可证 | MIT |
| 主要语言 | main |
| 下载量 | 1 |
| GitHub 星标 | 0 |
| 最近推送 | 2026-09-16 |
| 收录日期 | 2026-09-19 |
| 分类 | 安全与权限 |
事实信息来自公开插件目录快照(2026-10-01),介绍文案由本站再加工。
以下为插件仓库 README 全文(原始内容,由公开目录抓取整理)。
# dsh-repeat-tool-breaker
[](https://github.com/snailium/dsh-repeat-tool-breaker/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/dsh-repeat-tool-breaker)
[](LICENSE)
[](https://nodejs.org)
Hard break on an agent's repeated tool calls. A local, dependency-free
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plugin
that registers one synchronous gate on the public `ctx.tools.guard` API: when a
call repeats an action already seen inside the agent's sliding window, the call
is **denied before it executes** and the model gets an `isError` result starting
with `REPEAT_TOOL_BLOCKED` that quotes the previous result and says what to do
instead.
In one paragraph, the v1 → v2 story:
> **v1 lost** because it counted *byte-identical consecutive* calls while the
> model varied a presentation field (`description: '1st'|'2nd'|'3rd'`, churning
> `timeoutMs`) and ping-ponged between host spellings
> (`open-data.canada.ca` ↔ `open.canada.ca`) with a different `--max-time` each
> time — every call looked new, so the counter never advanced.
> **v2 wins** by deleting decoy arguments before any fingerprint is built, then
> counting *semantic* fingerprints (`net:`, `site:`, `sink:`, `cmd:`, `exact:`,
> `family:`, `verb:`) over a per-agent window of the last 12 calls, so a repeat
> has to change the actual resource — not its spelling — to pass.
## The three loops, and what catches each
| | Loop | Caught by |
|---|---|---|
| **A** | the same `read`/`write`/`bash` arguments again, verbatim | `exact:` (5) |
| **B** | `description: '1st'/'2nd'/'3rd'`, `command` unchanged | decoy arguments are stripped **before** fingerprinting, so the calls become byte-identical → `exact:` (5) |
| **C** | `curl --max-time 60 open-data.canada.ca` ↔ `curl --max-time 30 open.canada.ca` | `net:` (5) after host-alias folding, plus `sink:` (5) and `cmd:` (5) after volatile-flag stripping |
The sibling official plugin `@deepseek-ai/dsh-repeat-tool-reminder` (advisory, at
3/5/8 repeats) may stay on — the two compose, with the reminder as the soft nudge
and this breaker as the hard gate at 5.
## Requirements
- Node.js **>= 20** (developed and tested on 22).
- A DSH profile that exposes the `tools` service. Built and verified against
**`@deepseek-ai/dsh` 0.1.2-rc.1**.
- No runtime dependencies — the plugin imports only its own `lib/` modules (no
`cordis`, no schemastery), so it can be mounted straight from a path.
## Install
### Option A — list it as a profile bundle (recommended)
The package declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, so
it is a first-class profile bundle: no hand-written mount row is needed.
```bash
dsh plugin --profile add dsh-repeat-tool-breaker
```
Then add it to the profile's ordered bundle list
(`$DSH_HOME/profiles//package.json`):
```json
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-repeat-tool-breaker"
]
}
}
```
The bundle's patch layer mounts the plugin with **no `config:`**, so the
fail-loud `DEFAULTS` really are the defaults. To tune it, reconfigure the row by
id from the *profile's own* `cordis.patch.yml` — remember a patch replaces the
targeted row's whole `config` instead of merging into it, so restate every field
you want (see [Configuration](#configuration)).
Naming a bundle-less package in `dsh.profile.bundles` is a **hard boot error**
(`declares no dsh.bundle in its package.json`), which is why the manifest above
is required for this path.
### Option B — mount from a path (dev loop, no install)
Clone this repo and add an `insert` entry to a profile (see
[Configuration](#configuration) for the full snippet), then boot with the
overlay:
```bash
git clone https://github.com/snailium/dsh-repeat-tool-breaker.git
```
```bash
dsh --profile --patch /path/to/overlay.yml --dump-config # resolve check, does not boot
dsh --profile --patch /path/to/overlay.yml "reply ok" # real apply run
```
Here `name` must be an **absolute path** to this checkout's `index.js`, because
the package is not resolvable from the profile directory.
### Option C — install from npm, mount by hand
```bash
dsh plugin --profile add dsh-repeat-tool-breaker
```
`dsh plugin add` forwards to the profile's package manager, so the plugin becomes
a normal profile dependency and its `name` resolves to the package specifier
`dsh-repeat-tool-breaker` from a hand-written `insert` row. The `files`/`exports`
entries in `package.json` control what ships.
## How it stops a loop
Tool dispatch on the DeepSeek Harness runs:
```
tool/call
→ tools/pre-execute (allow / deny / ask)
→ tools/guard() ← THIS plugin's gate
→ tools/execute (the real tool body)
→ tools/post-execute
→ tools/result
```
Returning a `string` from a guard is a **final, monotonic denial**: it cannot be
re-allowed by listener ordering, and — critically — **the tool body never runs**.
That is what distinguishes a hard break from the official reminder, which only
injects a softer "you repeated X" message after the call already executed.
The guard is deliberately synchronous: no `await`, no DNS, no disk reads.
## How a call is fingerprinted
Each call contributes a *set* of fingerprints. Any one of them reaching its cap
denies the call, so dodging one (a new host spelling) still collides on another
(a new `sink:` or `cmd:`).
| Fingerprint | Built from | Catches |
|---|---|---|
| `exact::` | tool name + arguments with decoy fields deleted, keys deep-sorted | A, B |
| `cmd::` | verb + command with volatile flags (`--max-time`, `-s`, `--retry`, `timeout N`, `-sSL` clusters…) removed | C, B |
| `net:?` | `http(s)` URL with the scheme defaulted, `www.` and default ports dropped, host aliases folded, the fragment discarded, a trailing slash trimmed, and the **query kept** (sorted, tracking parameters removed) — the query is what makes `?page=2` a different resource | C, and it must NOT fire on pagination |
| `site:` | registrable-ish site of each URL (IP literals stand alone) — note this merges `api.github.com` into `github.com` | not capped by default; see [Local addresses](#local-addresses) |
| `sink:` | `-o`/`--output`/`-O`/`>`/`>>`/`tee` target of a shell command — except generic destinations (`/dev/null`, `-`, …), which say nothing about *which* resource was fetched | C |
| `family:http-fetch` | every `curl` / `wget` / `http` / `httpie` / URL-taking tool call | nothing by default — a volume budget no setting of which avoided false positives |
| `verb:` | the first non-wrapper command word (`sudo`, `timeout 30`, `FOO=1` are transparent) | tool-swapping within one verb |
### Local addresses
`localhost`, loopback, RFC1918 and link-local hosts are what a development loop
talks to — a dev server, a local inference endpoint, a container — and a `site:`
budget cannot tell them apart from a web crawl. `localHosts` decides:
| Value | Behaviour |
|---|---|
| `ask` (default) | the first local call that would be blocked asks the operator instead — once per turn |
| `deny` | never ask: local calls are counted and blocked like any other host, and the denial names this knob |
| `allow` | local traffic is never fingerprinted |
`ask` is the default because it is **fail-closed**. Every unattended outcome of an
approval is a denial — `rejected` (the session policy is `never`), `cancelled`
(the turn was aborted), and `unavailable`, which is what the registry falls back to
when no answerer is registered — so a headless profile degrades to `deny` on its
own. Nothing has to be configured per profile:
- a profile with a UI gets the prompt;
- a profile without one keeps blocking local calls, and the only difference from
`deny` is that the model's *first* blocked local call of a turn is told
"requires approval" instead of `REPEAT_TOOL_BLOCKED` (from the second one on the
breaker's own message applies again).
Set `deny` where even that is unwanted, or `allow` to stop counting local traffic
entirely:
```yaml
- id: repeat-tool-breaker
config:
localHosts: deny
```
What an approval buys: the local **target** fingerprints (`net`, `site`, `sink`,
`family`, `verb`) stop blocking for the rest of that turn. What it does not buy:
`exact` and `cmd` are untouched, because a byte-identical repeat is a loop whether
or not it points at localhost — and a call that mentions even one public URL is
not a local call at all. Declining an ask stops the asking and behaves like `deny`
until the next human message.
An ask is only ever made when local traffic is the *only* reason the call would be
denied, so a real repeat is a straight denial rather than a prompt.
### Counting rules
- State is a **per-agent sliding window** (`window`, default 12 calls) held in a
`WeakMap` keyed by the live `Agent` object — one agent's loop never trips
another's, and subagents get their own budget.
- A call is denied when a fingerprint **already appears `limit - 1` times** in the
window, i.e. when the current call would be the `limit`-th occurrence. The first
occurrence of anything is therefore always allowed.
- The guard **commits on both outcomes**, but *what* it commits differs, and that
difference is load-bearing:
- an **allowed** call commits every fingerprint it carries — the action really
happened, so it owns its share of the budget;
- a **denied** call commits only the fingerprints that **hit their cap**. The
action never ran, so it must not spend budget on a resource it never touched.
A measured run showed the cost of getting this wrong: a denied
`curl https://example.org` poisoned `net:example.org/`, after which the model
could not fetch that URL through *any* tool for the rest of the turn. The
hitting fingerprints are already at their cap, so re-attempting the blocked
call stays blocked either way.
- A real **user message** (`agent/pre-step` with source `kind: 'user'`) clears that
agent's window. Plugin notices and tool results do **not** — otherwise the
breaker's own denial would reset the budget it is enforcing.
- Excluded tools (`exclude`, default `todo_write`; `*`-wildcards supported) are
fully transparent: they neither count nor reset.
## Configuration
Mount via a profile bundle (Option A above — no `config:` in the bundle layer,
defaults apply), a `--patch` overlay, or a profile's `cordis.patch.yml`. The
plugin exports an object form (`{ name, inject: ['tools'], apply }`);
`inject: ['tools']` defers `apply` until the real `ToolRuntime` service is live,
at which point `ctx.tools.guard` is the genuine method.
```yaml
- insert:
- id: repeat-tool-breaker
name: dsh-repeat-tool-breaker
config:
window: 12 # recent calls per agent that participate
localHosts: ask # ask | deny | allow — see "Local addresses"
previewChars: 400 # truncation for quoted fingerprints
resultPreviewChars: 800 # truncation for the quoted previous result
exclude: [todo_write] # never counted, never resets (*-wildcards ok)
include: [] # non-empty = ONLY these names/patterns count
ignoreArgs: # merged over the defaults
'*': [description, timeoutMs, run_in_background, justification, reason, title, comment]
bash: [description, timeoutMs, run_in_background, justification]
hostAliases: # merged over the defaults
open-data.canada.ca: open.canada.ca
limits: # merged over the defaults; null = uncapped
exact: 5
cmd: 5
net: 5
sink: 5
site: null # volume budgets: off by default, see "Tuning"
'family:http-fetch': null
'verb:curl': null
'verb:wget': null
```
Merge semantics, which matter when retuning:
- `ignoreArgs`, `hostAliases` and `limits` merge **one level deep** over the
defaults, so you can add one host alias or retune one cap without restating the
table.
- Scalars replace; the arrays `exclude` and `include` **replace outright**, so a
two-entry `exclude:` list is the whole list, not an addition to the default
`todo_write` entry.
- A patch replaces the targeted row's whole `config`, so `config` keys are not
inherited from the bundle layer.
Every value is validated fail-loud in `apply`: `window >= 4`, every limit either
`null` or a finite number `>= 2` (a cap below 2 would deny the *first* call), and
preview caps `>= 1`. Config keys removed in 0.2.0 (`denyAfter`, `warnAfter`,
`registerAdvisory`, `maxSamePath`, `readTools`, `matchReadBySubstring`) throw with
a pointer at their replacement rather than being ignored, so an upgraded profile
cannot silently lose its tuning.
(The `- insert:` list is required to **add** a new plugin; a flat `- id:` entry is
a reconfig of an already-present id and fails with "entry not found" for a plugin
that isn't yet in the composed tree.)
### Tuning, and how these numbers were chosen
The table mixes *precise* caps with *broad* ones, and the difference matters:
- **precise, resource-scoped, cap 5**: `exact`, `cmd`, `net`, `sink`. These fire
only when the same action actually happens again, and they are what catches
loops. Every lower value was tried against real work and each produced a false
positive. A cap of 2 leaves no room for the most common *non-loop* repeat: the
first attempt fails for a reason that has nothing to do with looping — a
precondition the harness enforces, a DNS failure — and the correct response is
to retry the same call; at 2 that retry is what gets blocked, and the only way
forward is to cosmetically change the call, which is exactly what this plugin
exists to stop. At 3 the retry fits, but dense legitimate work still tripped,
because `sink:` is path-only **by design** — its whole job is to catch one
destination rewritten with ever-changing content — so a shell cycle that writes
the same file several times while iterating looked exactly like a loop. At 5 an
ordinary edit/test cycle fits, and a call that keeps failing is still stopped on
its fifth attempt.
- **not counter-based at all**: file operations. There is no `readpath` or
`writepath` limit. A file action is identified by its **position** through
`exact:` — the same file at the same offset, or the same replacement string, is
the same action and is denied; a different offset or a different region is a
different action and is never blocked. 0.2.0 shipped path-only counters for
these and they both had to be removed after blocking ordinary work on the
reference deployment (see [File operations](#file-operations)).
- **volume budgets, off by default**: `site`, `family:http-fetch`, `verb:curl`,
`verb:wget`. These counted how MUCH one site or one verb was used. They are all
`null` now, because a volume budget cannot tell a crawl from a session that is
simply making progress, and every value tried produced a false positive on a
real one:
| Setting | What it blocked |
|---|---|
| `family:http-fetch: 4` | a task asking for the status code of **four different URLs** (blocked from the second) |
| `site: 3` | ordinary development calls that merely *mentioned* a loopback URL |
| `site: 3` | a session paginating a GitHub commit list — `api.github.com` and `github.com` share one budget, so it tripped after three fetches |
The last one is the clearest argument: the agent's own comment in that session
was `# Fetch page 2 of openvino commits using a script file to avoid repeat
detection` — a volume cap that pushes an agent to *work around the breaker*
instead of changing approach is worse than no cap at all.
Repetition is what this plugin detects, and the resource-scoped caps do that:
`exact`, `cmd`, `net`, `sink`. If you do want a crawl budget, set one:
```yaml
- id: repeat-tool-breaker
config:
limits:
site: 30 # at most 30 fetches per site per window
'family:http-fetch': 60
```
### Deliberate deviations from the v2 specification
All of these came out of running the plugin against a live model on the reference
deployment.
1. **No path-only counter for file tools at all.** The spec folded reads and
writes of one path into a single `sink:` counter, which denies the second half
of the ordinary pair `read foo.ts` → `write foo.ts`. 0.2.0 replaced it with
separate `readpath`/`writepath` counters and 0.2.2 removed both, because a
path-only counter cannot see POSITION: it blocked re-reading a file that was
being edited, and blocked the third iteration on a single document. File
actions are identified by `exact:` alone, which is position-aware by
construction. `sink:` still means what §3.6 defined it as: where a *shell
command* writes its bytes.
2. **`pathAliases` is gone (0.2.4).** The spec's list (`path`, `filePath`,
`file`, `target_file`) had to gain `file_path`, the key dsh's own file tools
actually use — but that key existed only to feed the path-only `readpath`/
`writepath` fingerprints, which 0.2.2 removed. 0.2.4 deletes the inert key and
its `firstPathArg` helper, so the documented configuration is exactly what the
code reads. A config that still lists `pathAliases` is accepted and ignored.
3. **Generic sinks are not fingerprints.** `curl -s -o /dev/null -w '%{http_code}'`
is the idiomatic way to ask for a status code, and treating `/dev/null` as
action identity made four *different* URLs collide on `sink:/dev/null` starting
with the second.
4. **The volume caps ship at 6, not 4**, and **a denied call commits only the
fingerprints that hit.** Both were changed after live runs: the first because a
four-URL batch was blocked, the second because a denied `curl` was charging
`net:` for a URL it never fetched, locking the model out of that URL entirely.
## Development loop (dependency-free)
`cordis.patch.yml` in this repo is a ready-made overlay — point its `name:` at the
absolute path of this checkout, then:
```bash
# 1) prove the overlay + module resolve (prints the composed tree; does NOT boot)
dsh --profile --patch ./cordis.patch.yml --dump-config | grep repeat-tool-breaker
# 2) real apply run on a SAFE profile
dsh --profile --patch ./cordis.patch.yml "reply ok"
```
Two things worth knowing:
- **Never point this at a profile that serves a live UI** (in the reference
deployment that is the `web` profile). Boot a headless test profile, or an
isolated `DSH_HOME`, instead.
- Step 1 does not import the module, so a syntax or resolution error only surfaces
in step 2. To confirm the gate really is wired in step 2, add a temporary
`console.log(typeof ctx.tools.guard)` at the top of `apply` and remove it after
— the shipped file intentionally logs nothing.
## Acceptance
```bash
npm test # node --test test/breaker.test.js
```
30 tests, no model or endpoint required. The suite mirrors the v2 spec's table
(T1 ping-pong, T2/T3 description decoys, T4 unrelated calls, T5 curl↔wget, T6
exclusion, T7 per-agent isolation, T8 volatile flags, T9 normalizer units, T10
read paths, T11 denied calls still spend budget) and adds the plugin-level wiring
(T12: the guard denies, quotes the previous result, survives a plugin notice,
resets on a human turn; T12c: the fail-loud config contract) and the documented
shape of the shipped defaults (T14/T14b: volume is not a loop signal) and
pagination (T14c: `?page=N` is a new resource, re-fetching one page is a loop).
Three assertions worth singling out, because they are the ones that would have
caught v1 — or that caught v2's own defaults:
- every fingerprint of a `description: '1st'/'2nd'/'3rd'` call is asserted to
contain neither the decoy text nor the `timeoutMs` value;
- the deny path is asserted to be reached for host-spelling ping-pong whose
`exact:` fingerprints differ;
- one failed attempt is asserted to leave room for the identical retry (`T2b`),
while a call that keeps failing is still blocked;
- the `localHosts` matrix is asserted end to end (`T17`–`T22`): a mixed call is
never askable, an approval exempts targets but not `exact`, and a refusal stops
the asking until the next human turn;
- four *different* URLs writing to `/dev/null` are asserted to all be allowed, and
a denied call is asserted **not** to spend `net:` budget on the URL it never
fetched.
### Verified on a real model
Beyond the unit suite, the plugin was driven end-to-end through the official
`dsh-container` harness (`ghcr.io/snailium/dsh-container/dsh`) against a local
Qwen3.8-27B on llama.cpp, in a throwaway `DSH_HOME`:
| Scenario | Result |
|---|---|
| `curl -s -o /tmp/od.html https://open-data.canada.ca/` (`description: '1st'`) then the same fetch of `https://open.canada.ca/` (`'2nd'`) | 1st executed; 2nd **blocked before execution**, hits `net:open.canada.ca/ 2/2` and `sink:/tmp/od.html 2/2` |
| `echo hello-repeat` twice, `description` `'1st'` / `'2nd'`, `timeoutMs` 60000 / 1000 | 1st executed; 2nd **blocked**, hits the identical cleaned command |
| four **different** URLs, one `curl` each | all four allowed and returned 200 |
### Real-pipeline check (no model needed)
`test/pipeline.e2e.mjs` drives the genuine `ToolRuntime` with a stub `bash` body,
which proves the denied call's body is never entered — offline and deterministically.
It needs the dsh packages resolvable, so it is not part of CI:
```bash
DSH_NODE_MODULES=/path/to/dsh/node_modules/@deepseek-ai npm run test:pipeline
```
### Full-boot compatibility check (any dsh version)
`test/compat/` boots a **real** `dsh` of the version under test with this plugin
mounted as a profile bundle, and drives it with a scripted mock model — no GPU,
no real endpoint. `mock-llm.py` speaks enough of the OpenAI streaming protocol to
make the agent issue the *same* `bash` call four times in a row, and
`run-compat.sh` asserts the trajectory: attempts `1..cap-1` executed, the rest
denied with `REPEAT_TOOL_BLOCKED`.
```bash
DSH_PREFIX=/tmp/dsh-compat
mkdir -p "$DSH_PREFIX" && cd "$DSH_PREFIX" && npm init -y
npm install --no-audit --no-fund @deepseek-ai/dsh@
cd
DSH_PREFIX=$DSH_PREFIX ./test/compat/run-compat.sh
```
It checks what unit tests cannot: that the loader accepts the `dsh.bundle`
manifest, that the bundle's patch layer mounts the row, that `apply()` runs with
`inject: ['tools']` satisfied, and that a denial reaches the model as an
`isError` tool result. Reference output on `@deepseek-ai/dsh` 0.1.5-rc.2:
```
=== dsh under test ===
0.1.5-rc.2
=== bundle mounts? ===
ok
=== cap for action identity: 3 (mock issues 4 identical calls) ===
=== real headless run ===
compat run complete
=== trajectory ===
attempt 1: isError=False | compat-check
attempt 2: isError=False | compat-check
attempt 3: isError=True | Error: REPEAT_TOOL_BLOCKED: ...
attempt 4: isError=True | Error: REPEAT_TOOL_BLOCKED: ...
COMPAT: PASS (2 executed, 2 denied, cap=3)
```
## Releasing
Publishing runs through `.github/workflows/publish.yml`, which is
`workflow_dispatch`-only — nothing is published as a side effect of a push or a
release, and the job refuses to republish a version that already exists.
```bash
# 1. bump the version and update CHANGELOG.md, commit, push
# 2. trigger the release
gh workflow run publish.yml -f dry-run=false
```
Authentication uses **npm Trusted Publishing (OIDC)**: the workflow needs
`id-token: write` (already set) and a matching trusted-publisher connection on the
npm package page — repository `snailium/dsh-repeat-tool-breaker`, workflow
filename `publish.yml`, environment empty. No long-lived token is required, and
provenance is generated automatically.
Two things that will save you time:
- **Allow the right action.** A trusted-publisher connection created after
2026-09-03 defaults to allowing only `npm stage publish`. If direct
`npm publish` is not selected under "Allowed actions", the registry answers
`403 ... OIDC permission denied for this action`. Connections cannot be edited:
delete and recreate.
- **Debugging a 403.** Run `gh workflow run publish.yml -f dry-run=true -f debug-oidc=true`
to print the OIDC claims npm authorises against (`repository`,
`job_workflow_ref`, `aud`, …) and compare them with the connection's fields.
The npm CLI must be >= 11.5.1 and Node >= 22.14.0 for OIDC; the workflow upgrades
the npm CLI explicitly because Node 22 bundles an older one.
## Scope and verification status
**Verified**
- **Deterministic suite** (`npm test`) — 30 tests covering the full fingerprint
matrix, decoy stripping, host folding, sink extraction, window arithmetic,
per-agent isolation, the user-message reset, and the fail-loud config contract.
Runs in CI on Node 20 and 22 with no model or endpoint.
- **Loads and applies on a real DSH boot**, including as a profile bundle (the
`dsh.bundle` layer mounts the row by package specifier). Verified on
`@deepseek-ai/dsh` **0.1.2-rc.1** (the reference deployment) and
**0.1.5-rc.2** (via `test/compat/`, which boots the real CLI and asserts the
denial in the trajectory).
- **API surface is unchanged between those two versions**: `ToolGuard`,
`guard()`, the `tools/pre-execute` / `tools/post-execute` signatures,
`ToolExecutionInput`/`ToolExecution`, the decision unions and the
`agent/pre-step` payload all diff clean, and the tool names the plugin keys on
(`bash`/`pwsh`, `read`/`write`/`edit`, `web_fetch`/`web_search`) are stable.
- **Driven by a real model in the `dsh-container` harness** — the three scenarios
in [Verified on a real model](#verified-on-a-real-model), plus the real
`ToolRuntime` pipeline driven in-process with a stub tool body (which proves the
denied call's body is never entered).
**Not covered here**
- The live-model runs are manual, not part of CI: they need a local inference
backend and the `dsh-container` image. `npm test` is the CI gate.
**Intentional limits**
- The breaker is a safety net, not a semantic deduplicator. Two genuinely
different commands that happen to write the same non-generic file collide on
`sink:`, and that is by design — the denial message tells the model to work from
what it has.
- Fingerprints are computed from the *arguments*, never from the tool's output, so
a loop that varies only the working directory (`cd a && curl X` vs
`cd b && curl X`) still collides on `net:` but not on `cmd:`.
## Design notes
- **Counting lives in the guard and nowhere else.** That single locus is what
prevents the guard/post-execute double count, the reset-your-own-budget hole,
and the "denied call charges a resource it never fetched" hole.
- **Windows, not consecutive runs.** v1's run counter reset as soon as a different
signature arrived, which is exactly the `A, B, A, B` pattern class C exploited.
- **Fail loud in `apply`**: no schemastery `Config` export (keeping the plugin
dependency-free is deliberate — `cordis.resolveConfig` passes config through
unchanged when a plugin exports no `Config`), but every load-bearing invariant
is validated at load and throws rather than silently degrading.
- **The denial text is the model's only new information**, so it names the
fingerprints that hit with their counts, states explicitly that changing the
description / `timeoutMs` / `--max-time` / host spelling is not a new action,
and quotes the previous result inline. It contains no ``-shaped
markup.
- **State is in-memory only**; a resumed session starts fresh (same tradeoff as
the official reminder).
数据来源:公开的 DeepSeek Harness 插件目录与各插件 GitHub 仓库。本站为独立第三方目录,与 DeepSeek、幻方(High-Flyer)及插件作者均无隶属或背书关系。