Skills Plugins MCP Prompt Model 导航 博客 资讯 我的中心
安全与权限 #agent#cordis#deepseek-harness#dsh#dsh-plugin#guard

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.

snailium @snailium ⬇ 1 ★ 0 main

安装

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。

agentcordisdeepseek-harnessdshdsh-pluginguard
  1. 安装并启动 DeepSeek Harness:npx @deepseek-ai/dsh web
  2. 在终端执行上面的安装命令(CLI 会解析插件并核验来源)
  3. 用 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

[![CI](https://github.com/snailium/dsh-repeat-tool-breaker/actions/workflows/ci.yml/badge.svg)](https://github.com/snailium/dsh-repeat-tool-breaker/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/dsh-repeat-tool-breaker.svg)](https://www.npmjs.com/package/dsh-repeat-tool-breaker)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](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)及插件作者均无隶属或背书关系。

每日精选 Skill 推荐,免费送到你邮箱

输入邮箱,每天接收一个精选 AI Agent 技能推荐。完全免费,持续更新。

提交后我们会发送一封确认邮件,点击邮件里的链接才会开始收信。

完全免费,取消任意时间。我们不会发送垃圾邮件。