Skip to main content
P

dsh-phocinae

phocinae/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

Install

dsh plugin --profile web add github:phocinae/dsh-phocinae

README

English · 中文 · Español · Português · हिन्दी

斑海豹 Phocinae logo

dsh-phocinae

A local, non-generative decision layer for DeepSeek Harness. Plugins phocinae_ask and phocinae_gate.

npm dsh gate License

Plugin manifest (the dsh section of package.json)

▲ Read from the real package.json: bundle.patch points at cordis.patch.yml, and engines constrains both node and dsh.

Dangerous-command gate demo animation

▲ **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.

Unit and contract tests, real run

▲ Real node --test result: 57/57, including the auto-allow boundary and the unnamed-tool fail-closed regression.

Publish artifact self-check

▲ verify-artifacts: free of credentials and local paths, and lists what npm would actually ship.

phocinae_ask / phocinae_gate tools backed by a 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.

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).

npm i alone does not install the plugin

Two separate things have to happen, and only the second one mounts the plugin:

stepwhat it does
npm i dsh-phocinaePuts 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.5Adds 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:

whencommandresolved to
order 0.2.2 published + 2 mindsh plugin --profile p add dsh-phocinae0.1.2 — main: index.js, the build that cannot activate
same, but add dsh-phocinae@0.2.20.2.2 — ./index.mjs, skill present
same, bare add with minimumReleaseAge: 0 in the profile's pnpm-workspace.yaml0.2.2
order 0.2.2 published + 150 mindsh plugin --profile p add dsh-phocinae0.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:

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:

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:

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

contributionseamwhat it does
phocinae_askctx.toolsRuns a batch of typed questions against the local model. Every answer carries a calibrated confidence and an advisory escalate flag.
phocinae_gatectx.toolsJudges one command or action: allow / ask / deny, with confidence. Checks an action without running it.
approval gatetools/pre-execute waterfallScreens every tool call the configured patterns cover, before it executes.
phocinae skillctx.skillsTeaches 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

change0.1.20.2.2
tool registryctx.registerTool / ctx.tools.register probectx.inject(['tools'], …)
tool definitioninputSchema + handlerparameters + output + execute
gate answer sourceanswers[0] (array)answers[id] (object)
failure directionalways allowask by default (gate.failMode)
tool match default['bash']['*']
verdictnext(err){kind, reason}
gate questionboolean noulharm scale (harmless/risky/destructive)
gate.mode defaultdenyharm
escalationabsentescalate 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:

metricpublished (en)measured (en)published (zh)measured (zh)
local accuracy0.9060.90550.8480.848
escalation rate @ τ=0.645.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):

τescalatedLLM calls cutaccuracy on decisions kept local
0.5020.4%79.6%0.9523
0.6045.0%55.0%0.9936
0.7065.0%35.0%0.9986
0.8077.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 questionrecall on destructiveconfident passes that were destructive
noul — "should this be blocked?" (0.1.2's default)0.00—
tri-state — allow / ask / deny0.741
harm — harmless / risky / destructive (0.2.5 default)0.970

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

claimverdict
"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.

keydefaultmeaning
endpointhttp://127.0.0.1:8155/v1/systemonedecision service. Loopback only — the decision state carries raw tool-call text, so a remote host is a load-time error unless allowRemoteEndpoint: true.
modelPhocinae-Largha-150M-v1served model name
timeoutMs3000per request; one attempt, no retry
escalateAt0.6confidence below this marks an answer escalate: true
maxStateChars4096cap on the rendered decision state
permuteChoicefalsepermutation-average choice questions (4x forwards)
gate.enabledtruearm the pre-execute gate
gate.modeharmharm / tri-state / noul / off
gate.threshold0.8noul mode only: P(risky) that counts as a positive signal
gate.minConfidence0.45below this, an allow becomes a review
gate.autoAllowsee DEFAULT_AUTO_ALLOWcommands passed without asking the model
gate.tools['*']tool names to screen: exact, * globs, or *
gate.failModeclosedclosed = unreachable service asks a human; open = allow
gate.riskyDecisiondenynoul mode only: deny or ask on a positive finding
gate.audittruelog every verdict through the host logger

To screen nothing and only use the tools:

- id: phocinae
  config:
    gate:
      mode: off

To screen only the shell, and only ask:

- 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.

# 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

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

doccontents
ARCHITECTURE.mdthe mounting contract, the gate's decision order, and why each choice was made
SECURITY.mdtrust boundaries, what leaves the machine, and the failure modes
CHANGELOG.mdrelease history
bench/the benchmark scripts and their saved results
THIRD_PARTY_NOTICES.mdreused work and its licences

License

Apache-2.0 — see 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.

Related plugins