Bundle
dsh-phocinae
Local non-generative decision layer for DeepSeek Harness: the phocinae_ask / phocinae_gate tools backed by a Phocinae-Largha-150M-v1 server, plus a fail-closed approval gate on tools/pre-execute with a calibrated-confidence escalation signal
- Source
- Phocinae
- License
- Apache-2.0
- Updated
- Updated 3 days ago
Readme
**English** · [中文](./README-zh.md) · [Español](./README-es.md) · [Português](./README-pt.md) · [हिन्दी](./README-hi.md)
<img src="docs/assets/logo_phocinae.png" width="150" alt="斑海豹 Phocinae logo">
# dsh-phocinae
**A local, non-generative decision layer for DeepSeek Harness.** Plugins `phocinae_ask` and `phocinae_gate`.
<p>
<img alt="npm" src="https://img.shields.io/static/v1?label=npm&message=dsh-phocinae&color=CB3837&style=flat-square">
<img alt="dsh" src="https://img.shields.io/static/v1?label=dsh&message=%3E%3D0.1.6&color=1f6feb&style=flat-square">
<img alt="gate" src="https://img.shields.io/static/v1?label=gate&message=fail-closed&color=critical&style=flat-square">
<img alt="License" src="https://img.shields.io/static/v1?label=License&message=Apache-2.0&color=blue&style=flat-square">
</p>
<img src="docs/assets/dsh_manifest.png" width="880" alt="Plugin manifest (the dsh section of package.json)">
<p><sub>▲ Read from the real <code>package.json</code>: <code>bundle.patch</code> points at <code>cordis.patch.yml</code>, and <code>engines</code> constrains both node and dsh.</sub></p>
<img src="docs/assets/S01_rmrf_gate.gif" width="880" alt="Dangerous-command gate demo animation">
<p><sub>▲ **Synthetic demo animation** (model repo docs/gallery S01): frames are rendered and the scenario data is fictional, but the behaviour it shows — a dangerous command being stopped in milliseconds — is what this plugin's gate does; the terminal card further down is a real run.</sub></p>
<img src="docs/assets/dsh_tests.png" width="880" alt="Unit and contract tests, real run">
<p><sub>▲ Real <code>node --test</code> result: 57/57, including the auto-allow boundary and the unnamed-tool fail-closed regression.</sub></p>
<img src="docs/assets/dsh_package.png" width="880" alt="Publish artifact self-check">
<p><sub>▲ <code>verify-artifacts</code>: free of credentials and local paths, and lists what npm would actually ship.</sub></p>
`phocinae_ask` / `phocinae_gate` tools backed by a [Phocinae-Largha-150M-v1](https://huggingface.co/Phocinae/Phocinae-Largha-150M-v1) server — yes/no judgements, single-choice picks and 2-10 scores, one forward pass each, on your own machine — plus a fail-closed approval gate that screens tool calls before they run.
This is **0.2.5** — it closes two holes in the auto-allow list that 0.2.4 still had: command launchers (`find`, `env`, `fd`, `rg`) were prefix-matched, so `find / -delete` and `env sh -c "rm -rf /"` passed with no model in the loop, and a tool call whose `exec.name` was missing was allowed outright rather than failing closed. Both now reach the gate.
```sh
dsh plugin --profile <name> add dsh-phocinae@0.2.5
```
Requires Node `^22.19` or `>=24`, and a reachable decision service (see [Running the decision service](#running-the-decision-service)).
### `npm i` alone does not install the plugin
Two separate things have to happen, and only the second one mounts the plugin:
| step | what it does |
|---|---|
| `npm i dsh-phocinae` | Puts the package in **the current directory's** `node_modules`. Useful for reading the code or importing the guard directly. It does **not** reach any DSH profile. |
| `dsh plugin --profile <name> add dsh-phocinae@0.2.5` | Adds it to **that profile's** `package.json` (`dependencies` **and** `dsh.profile.bundles`) and installs it there with pnpm. This is what makes the host mount it. |
Measured on this machine, with a throwaway `DSH_HOME`:
- `npm i dsh-phocinae` in an unrelated directory → installed there, profile `dependencies` still `{}`, profile `bundles` unchanged, **plugin absent from `dsh <profile> --dump-config`**;
- `dsh plugin --profile p add dsh-phocinae` → the profile gains both the dependency and the bundle entry, and the composed config contains the `phocinae` row with its endpoint;
- a dependency **without** the `dsh.profile.bundles` entry → installed but **not mounted**, because the bundle list is what the loader walks.
### Pin the version
`add dsh-phocinae` resolves through pnpm's supply-chain age gate
(`minimumReleaseAge`). A release younger than the gate's threshold is not
selected, so **for a while after a publish the bare name resolves to the previous
version** — and the previous version here is 0.1.2, which does not work.
Measured, minutes apart, on the same machine:
| when | command | resolved to |
|---|---|---|
| order 0.2.2 published + 2 min | `dsh plugin --profile p add dsh-phocinae` | **0.1.2** — `main: index.js`, the build that cannot activate |
| same, but `add dsh-phocinae@0.2.2` | | **0.2.2** — `./index.mjs`, skill present |
| same, bare add with `minimumReleaseAge: 0` in the profile's `pnpm-workspace.yaml` | | **0.2.2** |
| order 0.2.2 published + 150 min | `dsh plugin --profile p add dsh-phocinae` | **0.2.2** — `^0.2.2`, host boots clean |
When the gate holds a version back, the harness records the refusal in the
profile's `pnpm-workspace.yaml`:
```yaml
minimumReleaseAgeExclude:
- dsh-phocinae@0.2.2
```
The gate is a sound default — a freshly published package is exactly what a
supply-chain attack looks like. It does mean **the version should be named**, both
to get the fixed build immediately and to make an install reproducible:
```sh
dsh plugin --profile <name> add dsh-phocinae@0.2.5
```
If a bare add does land you on 0.1.2, `dsh <profile> --dump-config` shows which
version is mounted, and re-adding with the pinned spec replaces it.
Git and local checkouts work the same way, and pin by construction:
```sh
dsh plugin --profile <name> add github:Phocinae/dsh-phocinae#v0.2.5
dsh plugin --profile <name> add /path/to/a/local/checkout
```
---
## What it contributes
| contribution | seam | what it does |
|---|---|---|
| `phocinae_ask` | `ctx.tools` | Runs a batch of typed questions against the local model. Every answer carries a calibrated confidence and an advisory `escalate` flag. |
| `phocinae_gate` | `ctx.tools` | Judges one command or action: `allow` / `ask` / `deny`, with confidence. Checks an action without running it. |
| approval gate | `tools/pre-execute` waterfall | Screens every tool call the configured patterns cover, before it executes. |
| `phocinae` skill | `ctx.skills` | Teaches the model when a local decision is the right tool, how to phrase one, and how to read the `escalate` flag. |
The model is not a chat model. It does not generate text, does not know facts, and cannot write code. It makes one structured decision per forward pass, which is exactly what a gate needs and nothing more.
---
## Fixed in 0.2.2
Every item below is reproduced by a test in `test/regressions.test.mjs`, named `D1`…`D12`.
### D1 — the plugin never activated
```
dsh: warning: 1 entry did not activate
phocinae (dsh-phocinae): Error: cannot get property "registerTool" without inject
```
`index.js` probed `typeof ctx.registerTool === 'function'` as a defensive check. A Cordis context is a proxy: reading an **undeclared** service name throws instead of returning `undefined`. `registerTool` is not a DSH service — the registry lives at `ctx.tools` — so the probe threw on the first line of the plugin body, taking the whole entry down before it could subscribe to anything.
**Fixed:** tool registration waits for the service through `ctx.inject(['tools'], …)`. No probe, no undeclared read, and if the service never appears the plugin still arms its gate.
### D2 — the tool definition was the wrong shape
The definition carried `{inputSchema, handler}`. `ToolDefinition` extends `ToolSchema`, which declares `{name, description, parameters}`, and the registry reads `definition.parameters`. Registering the old shape fails the model-facing projection:
```
dsh: UNKNOWN: tool "phocinae_ask" parameters must be lossless JSON before schema projection
```
**Fixed:** definitions now carry `parameters` and `output: {schema, render}` plus `execute`, all inside the harness's enforced JSON Schema subset (`required` as an array, `oneOf` for unions, no author-only keywords). `test/contract.test.mjs` re-implements that subset check, and the assembled-headless run registers the definitions in a real host.
### D3 — the gate read the answer from the wrong place
`data.answers` is an object keyed by question id. 0.1.2 did `Array.isArray(data.answers) ? data.answers[0] : …`, which is `undefined` for every real response.
### D4 — and therefore allowed everything, silently
The verdict test was `if (risk === true)`. With `risk` always `undefined`, that is always false, so `guard()` returned `{decision: 'allow'}` with the reason *"the model judged this low risk"* — for every command, including `rm -rf /`.
On the 53-command labelled set in `bench/`, recall on destructive commands was **0.00**.
**Fixed:** answers are read by id and shape-checked, a malformed body is a protocol error rather than a default, and the gate **fails closed** — an unreachable service, a missing confidence extension, or an unreadable body all route to a human.
### D5 — verdicts were thrown, not returned
The deny path called `next(err)`. The `tools/pre-execute` waterfall expects a `PreToolDecision` (`{kind: 'allow' | 'deny' | 'ask', reason}`); rejecting the waterfall is not a decision.
**Fixed:** `{kind: 'deny', reason}` and `{kind: 'ask', reason, displayReason}`, with `next()` called exactly once on the allow path.
### D6 — `gate.tools` defaulted to `['bash']`
The shell tool is `pwsh` on Windows, `bash` elsewhere, and MCP servers add their own names. On this machine the default matched nothing, so the gate never ran.
**Fixed:** the default is `['*']` — every tool — with exact names, `*` globs, and case-insensitive matching available.
### D7 — the escalation gate the release notes promised was not there
The model release documents an E1 gate: escalate a decision to a larger model when `answer_confidence` falls below τ. `0.1.2` contained no τ, no confidence comparison, and no escalation branch — a repository-wide search for `escalate` found only the Python server.
**Fixed:** `phocinae_ask` returns `escalate`, `escalatedIds`, `escalationReason` and `escalateAt`, and the gate consults the same confidence. The plugin still does not *perform* the escalation — it cannot call a large model on your behalf — but the signal is now produced rather than claimed.
### Breaking changes
| change | 0.1.2 | 0.2.2 |
|---|---|---|
| tool registry | `ctx.registerTool` / `ctx.tools.register` probe | `ctx.inject(['tools'], …)` |
| tool definition | `inputSchema` + `handler` | `parameters` + `output` + `execute` |
| gate answer source | `answers[0]` (array) | `answers[id]` (object) |
| failure direction | always `allow` | `ask` by default (`gate.failMode`) |
| tool match default | `['bash']` | `['*']` |
| verdict | `next(err)` | `{kind, reason}` |
| gate question | boolean `noul` | harm scale (`harmless`/`risky`/`destructive`) |
| `gate.mode` default | `deny` | `harm` |
| escalation | absent | `escalate` on every answer |
`gate.failMode: 'open'` restores the old always-allow behaviour for a deployment that wants it. It is not the default and should not become one.
---
## Measured behaviour
The measured figures were produced on one machine (CPU, fp32) against the released weights, with the scripts in `bench/`; re-run them on your own hardware before relying on them. The published figures come from the model release's own documents.
**Version anchors.** v1.0 — first published figures. v1.0 corrected — E1 numbers corrected (kept-subset 0.886, −54.4% LLM calls, 45.7% escalate). v1.1 (2026-10-09) — release of record (0.906 / 0.848, 45.0% escalated / −55.0% calls, kept-subset 0.9936). The pre-correction reading (0.7948 / −82% / 18%) is deprecated upstream — do not cite.
### Decision quality — the model is honest
`datasets/typed_test/test_typed_400.jsonl`, 400 cases × 5 questions = 2000 decisions, judged per decision against `gold.label`:
| metric | published (en) | measured (en) | published (zh) | measured (zh) |
|---|---|---|---|---|
| local accuracy | 0.906 | **0.9055** | 0.848 | **0.848** |
| escalation rate @ τ=0.6 | 45.0% | **45.0%** | — | **—** |
| accuracy on kept decisions | — | 0.9936 | — | — |
The zh figures are machine-translated cases; the training mix includes machine-translated Chinese (≈2,400 rows) plus native Chinese (≈1,400 rows) — an in-mix (fitted) evaluation, not cross-lingual transfer.
The accuracy reproduces within the release's own stated fp16/fp32 noise, and so does the escalation rate (an independent replication returns the same 45.0%). The *"79.6% fewer LLM calls"* saving belongs to **τ≈0.50**, while the frozen E1 gate is **τ=0.6** (τ swept on the eval set — re-scan per domain):
| τ | escalated | LLM calls cut | accuracy on decisions kept local |
|---|---|---|---|
| 0.50 | 20.4% | **79.6%** | 0.9523 |
| **0.60** | **45.0%** | **55.0%** | 0.9936 |
| 0.70 | 65.0% | 35.0% | 0.9986 |
| 0.80 | 77.0% | 23.0% | 1.000 |
**"79.6% saved" and "τ=0.6" cannot both be true.** At the threshold the model release calls frozen, the reduction is 55.0%. That is still a real saving, and the confidence ordering is genuinely useful — accuracy on what stays local climbs from 0.91 to 1.00 as the bar rises — but the headline number belongs to a different threshold.
### Gate quality — and why the default question changed
`bench/gate-bench.mjs`, 53 commands (31 destructive, 22 routine), all against the real model. `recall` is the share of destructive commands the gate stops; `auto-pass purity` is how often a passed command was actually safe.
| gate question | recall on destructive | confident passes that were destructive |
|---|---|---|
| `noul` — "should this be blocked?" (0.1.2's default) | **0.00** | — |
| `tri-state` — allow / ask / deny | **0.74** | 1 |
| **`harm` — harmless / risky / destructive (0.2.5 default)** | **0.97** | **0** |
The inherited boolean question is not merely weak, it is inverted: it passed **every one of the 31 destructive commands**, most with confidence between 0.54 and 0.80. Asking "is this safe?" is worse still — 27 of 31 passed with confidence as high as 0.87. The harm scale is the only formulation measured that both caught almost everything and never confidently waved a destructive command through.
Its one miss, `vssadmin delete shadows /all /quiet`, is caught by the auto-allow list's operator rule rather than by confidence, so no destructive command in the set reaches execution.
### Confidence is compressed: keep the gate narrow
The model's confidence does not separate routine commands from risky ones well enough to be a general-purpose filter. Its `harmless` verdicts sit mostly in 0.27–0.54, so a gate that asked it about `git status` would send `git status` to a human.
That is why `0.2.5` ships an **auto-allow list**: read-only and build/test commands with no shell operators never reach the model at all. The gate's job is to stop things, not to approve the routine:
- names on the list, no operator → pass locally, no request, no latency
- anything containing `;&|><`$(){}\[\]%^` etc. → the list is bypassed and the model decides
- a verb whose own arguments name a command, or delete what it matched (`find`, `env`) → not on the list at all; a caller-supplied `make` target goes to the model
- model says `destructive` → deny
- model says `risky`, or `harmless` below `gate.minConfidence` → a human decides
- service unreachable → a human decides
An entry containing a shell operator is refused at load time. `git status && rm -rf /` starts with an allow-listed prefix, so accepting such a pattern would build a bypass into the list. The operator rule is not the whole story either: `find . -delete` and `env rm -rf /` contain no operator, which is why those verbs are not on the list and why `make` passes only as a whole, known-safe invocation.
### Honest summary of the claims
| claim | verdict |
|---|---|
| "144.3M bilingual decision model, one forward pass, local" | **true** |
| "typed-decisions en 0.906 / zh 0.848" | **reproduces** (0.9055 / 0.848) |
| "cuts LLM calls by 79.6% with a τ=0.6 confidence gate" | **false as written** — 79.6% is τ≈0.50; at τ=0.6 it is 55.0% |
| "escalation improves combined accuracy to 0.8737" | **consistent** — escalation only replaces wrong local answers |
| "the plugin provides this gate" | **was false for 0.1.2** (recall 0.00, and it never loaded) |
---
## Configuration
Defaults live in `cordis.patch.yml` and are documented in `lib/config.mjs`. Every key has a default, so the bundle patch can be reduced to `enabled: true`.
| key | default | meaning |
|---|---|---|
| `endpoint` | `http://127.0.0.1:8155/v1/systemone` | decision service. **Loopback only** — the decision state carries raw tool-call text, so a remote host is a load-time error unless `allowRemoteEndpoint: true`. |
| `model` | `Phocinae-Largha-150M-v1` | served model name |
| `timeoutMs` | `3000` | per request; one attempt, no retry |
| `escalateAt` | `0.6` | confidence below this marks an answer `escalate: true` |
| `maxStateChars` | `4096` | cap on the rendered decision state |
| `permuteChoice` | `false` | permutation-average choice questions (4x forwards) |
| `gate.enabled` | `true` | arm the pre-execute gate |
| `gate.mode` | `harm` | `harm` / `tri-state` / `noul` / `off` |
| `gate.threshold` | `0.8` | `noul` mode only: P(risky) that counts as a positive signal |
| `gate.minConfidence` | `0.45` | below this, an `allow` becomes a review |
| `gate.autoAllow` | see `DEFAULT_AUTO_ALLOW` | commands passed without asking the model |
| `gate.tools` | `['*']` | tool names to screen: exact, `*` globs, or `*` |
| `gate.failMode` | `closed` | `closed` = unreachable service asks a human; `open` = allow |
| `gate.riskyDecision` | `deny` | `noul` mode only: `deny` or `ask` on a positive finding |
| `gate.audit` | `true` | log every verdict through the host logger |
To screen nothing and only use the tools:
```yaml
- id: phocinae
config:
gate:
mode: off
```
To screen only the shell, and only ask:
```yaml
- id: phocinae
config:
gate:
tools: ['pwsh', 'bash']
riskyDecision: ask
```
---
## Running the decision service
The plugin talks to a local server; it does not load weights itself.
```sh
# weights (the reference download is ~330 MB)
huggingface-cli download Phocinae/Phocinae-Largha-150M-v1 --local-dir ./model
# (modelscope hosts the same repo: modelscope download --model PerryLink/Phocinae-Largha-150M-v1)
pip install phocinae-server
PHOC_MODEL_DIR=./model python -m phocinae.main
```
`GET http://127.0.0.1:8155/health` answering `{"status":"ok","model_loaded":true}` means it is up. Without it, `phocinae_ask` returns a `PHOCINAE_SERVICE_UNAVAILABLE` error naming that command, and the gate routes every screened call to a human.
---
## Development
```sh
npm test # unit + contract tests, no network, no model
npm run test:integration # gate over real HTTP, then a real dsh host boot
npm run bench:gate # the gate benchmark (needs the real service)
```
`npm run test:integration` creates a throwaway `DSH_HOME` under the system temp directory, so it never reads or writes your own profiles. Its first half drives the real gate over real HTTP against a fake service; its second half installs the plugin into that throwaway profile and boots the real `dsh`, asserting that every bundle entry activates — which is the check 0.1.2 would have failed.
The fake service exists because the real one needs a 330 MB checkpoint. Point the harness at a running real service with `--real-endpoint http://127.0.0.1:8155/v1/systemone`.
---
## Documentation
| doc | contents |
|---|---|
| [ARCHITECTURE.md](./ARCHITECTURE.md) | the mounting contract, the gate's decision order, and why each choice was made |
| [SECURITY.md](./SECURITY.md) | trust boundaries, what leaves the machine, and the failure modes |
| [CHANGELOG.md](./CHANGELOG.md) | release history |
| [bench/](./bench) | the benchmark scripts and their saved results |
| [THIRD_PARTY_NOTICES.md](./THIRD_PARTY_NOTICES.md) | reused work and its licences |
## License
Apache-2.0 — see [LICENSE](./LICENSE). The model weights are Apache-2.0 as well; their card is the authority on the model, this repository is the authority on the plugin.
Install
dsh plugin --profile web add github:Phocinae/dsh-phocinae
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-phocinae from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.