dsh-canary
开发工具 活跃维护

dsh-canary

jwilson411/dsh-canary

给AI开发工具链植入不可伪造的金丝雀标记,自动拦截含该标记的工具调用参数与请求URL,避免恶意工具利用回声漏洞窃取敏感提示词,部署简单无额外依赖,接入后即时生效阻断风险。

0
Stars 标星
0
Forks 分支
0
Watchers 关注
0
Open Issues
JavaScript
主要语言
MIT
开源协议
34 KB
仓库大小
24 天前
最后推送
一键安装扩展 / 插件指令
dsh plugin --profile web add github:jwilson411/dsh-canary
git clone https://github.com/jwilson411/dsh-canary.git
git clone git@github.com:jwilson411/dsh-canary.git
README.md main

dsh-canary

A DeepSeek Harness function plugin that plants a canary string in
model-visible context and denies any tool call whose arguments or outbound URL
echo it back
. The denial happens before the tool runs, carries
code: 'CANARY_TRIP', and appends one JSONL line that does not contain the
canary.

The plant looks like this — the tag, then 32 hex characters:

DSHCANARY_…

Nothing legitimate in a session ever carries that value. It is not an argument
to anything, not an identifier, and not data. So a tool call holding it is not
ambiguous: something read the context and tried to send it somewhere.

What it is and is not

This detects echo-exfil of this planted canary. It does not detect prompt
injection in general, and it is not a red-team product.

  • Not a prompt-injection detector. It catches one behaviour: the planted
    canary being repeated into a tool argument or a URL. An injection that
    exfiltrates your actual data, edits a file, or persuades the model to skip a
    step is invisible here — the canary was never involved. A clean incident log
    means this canary was not echoed, and nothing more.
  • Not a red-team product. There are no attack payloads in this repository,
    no jailbreak corpus, no injection generator, and nothing that targets any
    harness. It ships a tripwire and a guard; producing something to trip them is
    not its job and not in the tree.
  • Not a classifier. The check is a substring match against one exact value.
    There is no model, no scoring, no entropy heuristic, and no threshold to tune.
    A near miss — a truncated canary, a different case, the value split across two
    fields — does not trip it, on purpose: a false trip denies a legitimate
    call, which is the failure mode that gets a plugin like this switched off.
  • Not a covert control. The canary is planted in a tool description the
    model can read, together with the instruction not to echo it and the statement
    that echoing it will be denied. A model that trips this has been told, in the
    same context, exactly what would happen.
  • Not data-loss prevention. It watches one seam — the arguments handed to a
    wrapped tool, and URLs you check — and nothing else. It does not watch files,
    egress, or a mail flow.

A trip is strong evidence that context reached an outbound argument. No trip
is not evidence that nothing did.

Install

dsh plugin --profile default add github:jwilson411/dsh-canary

dsh plugin forwards to pnpm inside $DSH_HOME/profiles/default, then
reconciles the profile: because this package's manifest declares
dsh.bundle.patch, it is appended to the profile manifest's ordered
dsh.profile.bundles list and its cordis.patch.yml becomes a layer. Remove it
the same way, with remove in place of add.

Pinned DSH release candidate

This package is written and tested against the pinned release candidate
0.1.1-rc.2 — @deepseek-ai/dsh-tools@0.1.1-rc.2 is pinned exactly in
devDependencies so tests run against one known API, and the peer range is
^0.1.1-rc.2, matching how the harness's own tool packages declare it.

The two seams this RC does not have, and what is done instead

The pinned release candidate exposes no system-prompt inject seam and no
tool-execute middleware seam
. This package does not invent either one, and
reaches into no private API.

what is needed what the RC offers what this package does
put the canary where the model reads it no prompt inject seam registers canary_context, whose description carries the canary. A tool description is model-visible by construction.
deny a call before it executes no execute middleware seam exports wrapExecute, applied by the host at its own call site.

The plant is therefore exactly as durable as the tool registry: registration
happens inside apply, so the Cordis fiber owns it, and stopping, updating, or
reloading the plugin retires the plant with no bookkeeping.

What it registers

Cordis plugin id canary (the row id in cordis.patch.yml)
Injects tools — a hard dependency; the plugin waits rather than degrading
Tools canary_context (the plant), canary_status (the tally)
Arguments neither tool takes any

canary_context returns { planted, prefix, reminder, plugin }. canary_status
returns { planted, prefix, incidents, lastTool, lastWhere, lastAt, log, plugin }.

Neither tool ever returns the canary. prefix is the first 12
characters — the ten-character tag plus two hex digits, which is enough to
confirm which plant is live and 120 bits short of enough to reconstruct it. A
status tool the model can call is a status tool an injected instruction can
call, so there is nothing there worth calling it for.

The library API

The tools are the visible half. The guard is the product:

import { plantCanary, wrapExecute, CanaryTripError } from 'dsh-canary'

const state = plantCanary()                       // or plantCanary({ canary })

const guarded = wrapExecute(tool.execute, state, { tool: tool.name })

try {
  await guarded(argsFromTheModel, exec)
} catch (error) {
  if (error instanceof CanaryTripError) {
    log.warn({ code: error.code, where: error.where, tool: error.tool })
    return
  }
  throw error
}

On a hit the inner execute is never called, one incident line is appended,
and the error propagates. On a miss the arguments are passed through untouched —
this plugin redacts nothing and rewrites nothing.

Two narrower entry points, for a host that owns a different call site:

import { assertNoCanary, assertNoCanaryInUrl } from 'dsh-canary'

assertNoCanary(requestBody, state, { tool: 'http' })   // throws on a hit
assertNoCanaryInUrl(candidate, state, { tool: 'fetch' })

Both throw and write nothing; wrapExecute owns the incident so that one
denial is one line whichever a host reaches for. scan(value, canary) is the
same walk exposed on its own, returning { where, path } or null.

What counts as an echo

The walk covers strings, arrays, and plain objects, and it scans object keys
as well as values — a payload keyed by the canary leaks it as surely as one that
holds it. Cyclic structures are walked once, not forever.

where is reported as url when the hit is:

  • under a key named url, uri, or href (case-insensitively), at any depth;
  • inside a string that is an absolute URL — one beginning with a scheme —
    wherever in the arguments that string sits;
  • in the path, query, fragment, userinfo, or host of such a URL;
  • in any of the above after percent-decoding, up to two passes — a canary
    pasted into a query string arrives encoded, and that is the exfil working.

and args otherwise — a URL embedded in a longer piece of prose is denied
like anything else, and recorded as args, because where names the field the
echo sat in rather than scraping URLs out of text. A url hit anywhere in the
walk wins over an args hit, because the outbound surface is the one worth
naming.

Absolute-URL detection here is deliberately shape-based rather than a fetchable
check: data: and javascript: are URLs for this purpose. Whether a URL may
be opened is a different question, for a different plugin.

The error

error instanceof CanaryTripError
error.code    // 'CANARY_TRIP' — always
error.where   // 'args' | 'url'
error.tool    // the tool whose call was denied
error.prefix  // the first 12 characters of the canary, never more
error.path    // dotted path to the offending field, or null
error.message // human-readable, and canary-free

The message carries the prefix and never the value. An error message is the
thing most likely to be logged, rendered, or handed straight back to the model
that just tried to exfiltrate the canary; putting the canary in it would hand
over the answer.

The incident log

Append-only JSONL, created 0600, one line per denied call:

{"ts":"2026-08-31T12:00:00.000Z","tool":"http_get","where":"url"}

Three fields, and the record is constructed from three scalars rather than
spread from a caller's object, so there is no path by which the canary reaches
the file. where is validated against args / url on the way in and on the
way back out, so a log appended to by something else cannot smuggle a value into
canary_status.

The whole canary is emitted in exactly one place, once, at plant time: the
canary_context description. Not the log, not the status tool, not an error.

A write failure is deliberately not swallowed — a tripwire whose audit trail
silently stops is worse than one that fails loudly.

Config

key type default
canary string generated a fixed plant, DSHCANARY_ + 32 lowercase hex
incidentLog string .dsh-canary.jsonl path of the append-only JSONL log

Set them from the profile's own cordis.patch.yml — note that an id-targeted
patch replaces the row's whole config, so restate every field you mean to
keep:

- id: canary
  config:
    incidentLog: /var/log/dsh/canary.jsonl

Resolution order is patch config, then environment, then generated: a patch
row is the deployment's stated intent, so it is not silently overridden by an
ambient variable. The environment fallbacks are DSH_CANARY and
DSH_CANARY_LOG, each used only when the patch row omits the key entirely.

Leave canary unset in production. A fresh 128-bit canary is minted on
every apply, so a plant lasts one plugin lifetime and nobody gets a second
session to work on the same value. Fix it only when the plant must be
predictable:

  • a test suite that has to assert on the value — this repository's own suite
    is the first consumer of the key, and every test below plants a fixed canary
    so the assertions are deterministic;
  • a fleet of workers that must share one plant.

A configured value that is not canary-shaped is rejected at apply time with
an InvalidCanaryError rather than quietly replaced by a random one: a silent
substitution makes every downstream expectation wrong, and a short one would
match half the arguments in the session.

Layout

package.json          manifest + `dsh.bundle.patch` — what makes this a bundle
cordis.patch.yml      the bundle's patch layer: one insert, one plugin row
src/canary.js         the pure half: mint, scan, deny, append
src/index.js          the plugin: `name`, `inject`, `apply(ctx, config)`, the tools
test/                 offline tests: the walk, a stub context, a hygiene scan
package-lock.json     the pinned dependency tree `npm ci` installs in CI

Tests

npm install
npm test

Offline by construction, and not merely by intention: every case is a string
walk, apply is handed a stub context that records registrations, tool results
are validated against the real @deepseek-ai/dsh-tools pinned to 0.1.1-rc.2,
and every incident log is written under the OS temp directory and removed
afterwards. test/hygiene.test.js asserts that the shipped source opens no
socket, no fetch, and no subprocess, that the tree carries no machine names,
mount paths, or credential-variable names, and that CI needs no credentials.

CI runs the same two commands on Node 22.x and 24.x with contents: read and no
secrets.

Version

Initial release, 2026-08-31. MIT.