dsh-threat-gate
Session-level threat scoring for the DeepSeek Harness — a faithful port of the
EntropyRuntime session threat tracker (PATCH-02, audit/threat_tracker.py).
Measures and records by default; vetoes only when you tell it to.
This plugin is the session plane, orthogonal to
entropy-guard (control plane:
can this tool be called at all) and
shape-guard (content plane: what
does relayed content carry). It watches the trajectory of a session — a
sliding window of recent tool calls — and folds it into a 0–1 threat score
with three levels: normal / suspicious / critical.
Scoring rules (weights and thresholds ported value-for-value)
| Rule | Shape | Weight |
|---|
| A. Dense repeats | one signal ≥3 times in the window | +0.1 × (count − 2) |
| B. Recon→exec order | a reconnaissance-family tool followed by an execution-family tool in the window | +0.3 |
| C. Rapid denials | ≥2 calls actually refused by a guard in the window | +0.2 × count |
Levels: critical ≥ 0.70, suspicious ≥ 0.40.
Two semantic corrections in 0.1.1, both found by an independent build-side
audit (see the defect ledger below):
① "Execution family" means running code (bash/pwsh/run_code/shell/ execute/node/python), not write/edit. Including file writes made the
ordinary coding loop — read a bit, then edit — score +0.3 every time, so
read×3→write read as suspicious.
② Rule C counts only genuine refusals, never bare isError. Routine
failures (network, missing path, disk full) are not escalations; counting them
meant two failed writes reached critical, and a session at critical with
vetoOnCritical is locked out.
The full item-by-item port comparison is in DEVIATIONS.md.
What the level means (semantics, ruled in 0.1.7)
The level names a trajectory shape, not a danger verdict. Measured on a
real, entirely benign plugin-development session (245 tool calls replayed against
the published artifact): 90% of the time above normal, 40% at critical
(FINDING-F1 plus the distribution follow-up). The rule shapes — dense repetition,
recon-then-exec — are also the shape of ordinary coding; they do not separate
intent.
Consequences:
vetoOnCritical is not recommended. With 40% of benign work at critical,
enabling it turns an emergency brake into a random speed bump: write/edit/
pwsh refused four times in ten, and the session crawls along the read-only
escape path (T1). If you need a veto, that lives on the control face
(entropy-guard's utility gate, which governs targets and gears).
- Do not read
critical as "danger". It is a measurement of trajectory
shape, recorded in the chain — it neither confirms nor rules out hostile intent.
- Rule C's object, measured (MP-2026-10-13-01).
rapidDenied reads the
guard's return string: the tracker picks it up at index.js L67 via
error.message ?? error.info.reason, and the guard's own wording is confirmed
to match the pattern (measured by driving a real EntropyController.guard()
at G0) — while which of the two fields carries it in the live host is not
separately measured (the ?? chain reads both, so the conclusion holds
either way). The chain's own call_rejected.reason carries the short form
and does not match; testing the pattern against the chain string was an
object-substitution error, caught and retracted. Suite anchors: the pattern
answers the return string, the short form provably does not match, three
worded refusals reach critical, and without the wording the component stays
zero.
- A
critical level decays on its own as the window slides (measured:
0.8 → 0.6 → 0.3 with no intervention) — it is not a terminal state and needs
no reset.
Install
npm install @cyd-prc/dsh-threat-gate
The bundle patch (cordis.patch.yml) inserts the row; every config field is
optional. Default posture: measure and record only — set
vetoOnCritical: true to have the gate monotonically deny side-effecting
calls while a session is at critical.
| Config | Default | Notes |
|---|
windowSize | 10 | Counting window. An invalid value throws (0.1.1); it no longer falls back silently |
vetoOnCritical | false | When true, denies side-effecting calls at critical. Read-only tools stay available — a critical session keeps a read-only escape path |
readOnlyPattern | read-only verbs | What counts as read-only (0.1.2). Covers verb-first (read, grep, glob), verb-last (job_list), and *_status/*_state/*_info. Anything unmatched is treated as side-effecting (fail-closed). Network tools are deliberately not exempt — web_fetch/web_search read outward, and cutting egress is the point of a lockdown |
denyPattern | guard refusal wording | The "genuinely refused" test; override to match another refusal source |
Surface
- Tool —
threat_status (read-only, exempt from the gate): score, level,
rule breakdown, window, level-change count.
- Command —
/threat → status (default) | reset.
- Audit chain — same chain as entropy-guard / shape-guard
(
$DSH_HOME/entropy-guard/<agent>.jsonl), threat_level events carry
{from, to, score, window, rules} — so one export answers "what the session
did and how dangerous its trajectory looked".
Upgrade path (reserved)
shape-guard chain entries carry a threat_score_hint field that is
deliberately null today ("an unmeasured correlation is not a measurement").
Threat-gate's score is the future value of that field: when both plugins are
installed, the shape face can tighten policy as the session score rises.
This version only measures and records — the coupling is intentionally
one-directional until the test side signs it.
Defect / change ledger
0.2.4
- The veto anchor is pinned. When
vetoOnCritical is on, report() must still name the read-only escape path (read-only tools stay available); when off, it must name the recommended posture (measure & record only). Three places declare the veto's semantics (config comment, report line, README semantics section) — the anchor keeps them from drifting apart, which is the failure mode where a wording edit in one place orphans the T1 escape path.
- Measurement-basis correction (0.2.2's heap number). The 0.2.2 note said "+6.8 MB heap" for a 10k-line harvest; that measured
heapUsed at harvest end. A second measurement holding the full 10k-entry result resident reads +15.6 MB. Both are real; they count different things (transient working set vs retained result). Stated so the numbers stop pretending to be one number.
0.2.3
- What rule C observes, pinned (MP-2026-10-13-01). The suite now anchors the denial-wording object: the pattern answers the guard's return string, the chain's short form provably does not match, three worded refusals reach
critical, and without the wording the component stays zero. Plus tools/check-denial-wording.mjs, a workspace-only probe driving a real EntropyController at G0 (SKIPs cleanly inside the artifact). No runtime change.
0.2.2
- The baseline now survives restarts (ticket 2: history harvested from the chain). At controller creation, the session's recorded history is replayed into the baseline (
baseline.fromChain: true, default) — measured on the live profile: after a restart the deviation read warming (0/20) while the chain already held 190 samples; now activation reads ready with baseline: chain. Named sources, never merged silently (acceptance anchor A/C): the reading carries baseline.origin ∈ {live, chain, merged}, and the report prints it. warming is never masked (anchor B): the live sub-state (live.state/live.samples) keeps reporting warming until this process has earned its own baseline — an unknown is never presented as a settled reading. The kill switch baseline.fromChain: false restores the 0.2.0 live-only behavior (no default change without a way back). Replaying the same chain twice is byte-identical (anchor D, pinned). Measured cost: harvest 1k lines ≈ 20 ms, 10k lines ≈ 87 ms (+6.8 MB heap) — roughly linear; per agent, once per process. Threat-model boundary stands: the baseline is the session's recorded normal, and init epochs mark process boundaries the live side has no concept of — the merged baseline knowingly spans them.
0.2.1
tools/replay-chain.mjs: replay a session from its chain and diff it against the live-recorded transitions — the instrument that closes FINDING-F1's open cell (pre-dispatch gate_decision order vs post-execution tools/result order). On the real session chain: 24/24 transitions match, bit-identical including every IEEE-754 tail. Two replay disciplines, both pinned by anchors: gate-denied decisions are skipped (they never executed, so the live stream never saw them — rule C in replay is always 0, declared not hidden), and an init entry restarts the window (process epochs: the live tracker's state is per-process — the one initial mismatch on the real chain was exactly a restart boundary, measured at seq 376/392). Read-only by construction; exit 1 on any mismatch.
0.2.0
- Deviation-from-self (semantics-3): the reading now also compares the window to the session's own baseline. The absolute-threshold levels stay (compat; re-named in 0.1.7 as trajectory shape), and beside them each window is measured against the session's own history: the three rule components (unchanged — no new judges) get per-component quantiles (
p50/p90) over the session's past readings, and aboveP90 marks a rhythm shift — a burst, a new tool family, an egress pattern. The promise, pinned by anchors: the steady benign rhythm that reached critical against absolute thresholds (FINDING-F1) is not a deviation from itself; a varied prefix followed by an execution burst is. Threat-model boundary, stated rather than hidden: a self-baseline catches rhythm changes; an attack that perfectly continues the session's own rhythm reads as normal — the price of a baseline with no labels. Deviation is measurement only and never feeds vetoOnCritical (still not recommended). threat_level chain entries carry deviation beside rules (additive; readers recompute from bytes either way). The baseline excludes the current window from its own history, so a shift is compared against what the session did before it — never diluted by the shift itself. Warmup: warming until 20 past-window readings exist.
0.1.7
| # | What was observed | What the fix is |
|---|
| 12 | The level was readable as "danger" while measuring trajectory shape. On a real benign dev session the published artifact sits above normal 90% of the time and at critical 40% (FINDING-F1 + the 245-call replay) — the rule shapes are the shape of ordinary coding, so the dynamic range was spent on benign work | Semantics ruling (documented, not re-engineered): the level is a measurement, critical ≠ danger, vetoOnCritical is explicitly not recommended (an emergency brake that fires 40% of the time is a random speed bump), and report() says so. A baseline-deviation reading (per-session-type) is registered as the follow-up direction, not built here |
0.1.6
| # | What was observed | What the fix is |
|---|
| 11 | D-1: the chain CLI crashed on every fresh chain. verify-chain.mjs read reading.sealed.count while the grader returns sealed: null for a never-sealed chain — the default state of every new chain killed the tool. No CLI-level test existed to catch it | The print path guards the null; the suite now carries its first CLI-level case (a fresh chain must exit 0 with VERIFIED). Found by the interim verifier (VR-2026-10-04-01) |
0.1.5
| # | What was observed | What the fix is |
|---|
| 10 | The vendored ledger is now a real dependency. The byte-identical copy of shape-guard's lib/ledger.js (the V1 family: one fix landed in one package and not the other, twice) is retired; lib/ledger.js is a pure re-export of @cyd-prc/dsh-audit-chain, bundled with an exact pin so the installed artifact stays self-contained, and the release check verifies the bundled copy matches the pin | lib/VENDORED.md retired into a historical note; the re-sync rule is now npm install's job. The shared contract's clause 8 and every earlier grading fix arrive with the dependency, and cannot drift again. Packaging note: bundleDependencies needs npm for packing — pnpm refuses bundled deps under its default isolated linker (ERR_PNPM_BUNDLED_DEPENDENCIES_WITHOUT_HOISTED); pnpm users reproducing a pack need nodeLinker: hoisted. |
0.1.4
| # | What was observed | What the fix is |
|---|
| 9 | Vendored ledger re-synced to shape-guard 0.1.6 (S4 / contract clause 8). The 0.1.5 copy graded a torn tail line (a crash mid-append) tampered on its own — an availability event in the hostile grade — and its writer could fuse a new entry into the torn line, losing it | Whole-file re-copy per the VENDORED.md rule (sha256 3A547663…, byte-identical to the source): corrupt is reported, never graded on its own, and the writer terminates a torn tail before appending. The provenance table also back-fills the 0.1.5 row it missed. The structural fix (a shared dsh-audit-chain package) stays open |
0.1.2
| # | What was observed | What the fix is |
|---|
| 6 | vetoOnCritical denied everything, read-only tools included (T1, measured by the test side): at critical even read/grep were refused, so the session had no read-only escape path — it could only wait for a human to run /threat reset. Three places (the patch comment, lib/config.js, report()) said "side-effecting calls", so the docs and the implementation disagreed | Added readOnlyPattern: read-only tools are always allowed at critical (precedent: entropy-guard keeps G0 available while suspended — the paper's Theorem 4 recovery path). All three descriptions now say "side-effecting" and name the exemption. Anything unmatched counts as side-effecting (fail-closed). Anchors: the three T1 cases plus tools/live-check.mjs |
| 7 | Is a network tool read-only? While widening the pattern, web_search was briefly exempted | Deliberately not exempt. web_fetch / web_search only "read", but they read outward; allowing them at critical leaves an egress path. This makes the plugin stricter than entropy-guard's G0 here, and that choice is pinned as an anchor (T1 设计判断) |
| 8 | Neither verification tool shipped (T2): files had no tools/, so the community could recompute neither the chain reading nor the release check | Shipped tools/verify-release.mjs (package-agnostic), tools/verify-chain.mjs (driving this package's lib/ledger.js), and tools/live-check.mjs — the last one is T1's instrument: the Node suite cannot reach the guard closure, which is exactly why the doc/implementation mismatch got out |
| — | Vendored-ledger drift (V1), still open. lib/ledger.js is byte-identical to shape-guard 0.1.4, so a grading defect present there is present here too: one chain, three writers, two grader copies, one defect. The structural fix (a shared @cyd-prc/dsh-audit-chain that all three depend on) is not done — it needs a new published package and coordinated version bumps across three plugins | Tracked, not fixed here. records the source and the re-sync rule; the grading contract that both graders must implement is owned by the test side |
0.1.1
Three must-fix items plus a provenance resync, from an independent audit by the
build side. Every entry carries an anchor, and the anchors are written to
record the failure each fix removed, so it cannot return quietly.
| # | What was observed | What the fix is |
|---|
| 1 | Rule B's "execution family" included write/edit, so the ordinary coding loop — look, then change — scored +0.3 every time; stacked with rule A's density score it crossed the line: read×3→write = 0.40, read×5→edit = 0.60, both suspicious. Measured: 4 of 8 ordinary sessions did not stay normal | The execution family is narrowed to code execution (bash/pwsh/run_code/shell/execute/node/python). Afterwards 8 of 9 ordinary sessions stay normal, while read×3→bash is still suspicious — the genuine shape survives. Anchors: the two §1 阳性锚 cases and §3 |
| 2 | isError was treated as "an upgrade was refused": routine failures fed rule C, so two failed writes (ENOSPC) reached 0.80 ⇒ critical — and a critical session with vetoOnCritical is locked out, turning "the disk is full" into "the guard has halted me" | Added denyPattern (default: guard refusal wording <plugin> denied); only guard-shaped failures count. This is a heuristic and is declared as one in DEVIATIONS §2. Anchors: §2 阳性锚 plus §2 阴性锚, which proves a real refusal still scores — the fix did not throw the true signal away with the noise |
| 3 | lib/ledger.js was a stale copy of shape-guard 0.1.1, silently lacking the 0.1.4 fork-aware grading (liveForks / discontinuities / dangling-link rule / seal-bounded findings). One file existed in two versions on one machine, and later fixes would not have propagated | Re-synced wholesale to 0.1.4 (sha256 now matches the source), with provenance and maintenance rules recorded in lib/VENDORED.md |
| 4 | An invalid windowSize fell back silently: "5" or 10.5 quietly became 10, so a deployment believed it had set a window | Invalid values now throw, surfacing the config error at load time. Anchor: §5 |
| 5 | Two non-equivalent port points were declared "ported verbatim": rule B's families are (upstream's prelude is seven literals; the intersection is only / — upstream scores 0.00 on where this port scores 0.30), and the ordering constraint is (upstream is pure presence) |
0.1.0 (2026-10-03)
- First cut: faithful tracker port plus the dsh wiring (
tools/result signal
source, optional veto, read-only tool and command, shared-chain
threat_level events).
- Declared deviations (as in the design note): upstream's intent signals are
supplied here by tool-settlement events;
plan_gate.py's
"target-shape ∧ verb" predicate is not ported (dsh has no decompose face;
a target allowlist is a separate ticket).
- Superseded by 0.1.1, which corrects the "verbatim" claim for rule B.
License
MIT © Wang Miaosheng (ORCID: 0009-0003-2767-2421)