Skip to content

Releases

v0.10.0 — Aggressiveness levels, and card numbers detected at all

Choose a tag to compare

@CharlotteN7 CharlotteN7 released this 05 Sep 10:06

aggressiveness: high redacts the user's own prompt, and card numbers are detected at all

Why the user's own typing is now in scope at the top level

The plugin cannot know where the model is. A card number typed into a prompt bound for a
third-party provider is a compliance problem whether or not it was meant, and "the user chose to"
is not a defence a PCI auditor accepts.

Measured against a live provider, same prompt, level the only difference:

what the model received
medium the real digits — the model's own reply identified it as a Visa test PAN
high [REDACTED:dsh-dlp:payment-card-number:…]

At medium the model declined to echo it. That is the model's manners, not a control: the number
had already left the machine.

The three levels

low asserts nothing; every pass is exactly its own toggle. The level to move to when you need a pass off.
medium (default) every pass on, and no toggle can take one away. User typing stays exempt.
high medium, plus the user's own typed input is redacted too.

A toggle contradicting the level fails at load, naming the offending toggles and the fix, rather
than one of them quietly winning. For a DLP plugin, a config that lies about which passes run has
already failed.

Upgrade impact: medium's floor is exactly the nine schema defaults, so an install that never
wrote a toggle behaves bit for bit as it did in 0.9.0. One class of install breaks: anyone who
explicitly set a redaction toggle to false must now also set aggressiveness: low. It fails
loudly at mount with that named.

The level is deliberately not offered to the repo-local policy tier. Every other tightening a
workspace can request acts on text the workspace does not own; this one would put placeholders into
the user's own words.

Card numbers were not detected before this release

Fourteen published test numbers across seven issuers returned zero detections from either tier,
and the recommended secretlint preset's 28 rules contain no card rule. So high without a detector
would have been theatre for the exact case that motivated it.

dsh-dlp/payment-card-number is Luhn-validated, issuer-range and length checked, and narrows a
greedy match rather than accepting it — a card typed beside its expiry reports only the card.

False positives, measured at 100k samples per row: zero on timestamps, floats, Math.random()
printouts, ISBN-13, IPv4, and zero across the 3,000-file deepseek-harness checkout. The residual
2.70% on random 16-digit runs decomposes as 26.8% issuer-range coverage × 10% Luhn. Cost: tier-1
scanning roughly doubles, ~9 ms per MB.

Severity is medium, not high, deliberately: at high the non-configurable guard floor would
deny egress calls carrying a card, unoverridably, and this rule's false positives are ordinary long
numbers rather than malformed credentials. A deployment wanting the denial raises it from its
repo-local policy.

Not covered, stated rather than implied away: Maestro, CVV, expiry, track data, IBAN, national IDs,
tool arguments, and a card split across two separately-scanned surfaces.

v0.9.0 — Untrusted context redacted before the model sees it

Choose a tag to compare

@CharlotteN7 CharlotteN7 released this 04 Sep 21:11

Two paths that put untrusted text in front of the model unredacted

Both are on by default. An interactive session sees no change.

A repository's AGENTS.md reached the model verbatim

agent/pre-step splices the AGENTS.md / CLAUDE.md chain and the skill catalog into every turn.
Nothing sat between that and the provider. Verified against a real harness and a real model:

model's answer durable session log
before xoxb-987654321098-… verbatim raw token present
after [REDACTED:dsh-dlp:slack-token:acaef1a498cf] placeholder only

This is a workspace-controlled file. A repository you clone chooses its contents.

Webhook-delivered inbox text is now in scope

dsh-webhook admits third-party payloads as user messages. Those are covered now;
your own typing is not. The exemption is source.kind === 'user', read off the four call sites
that supply it rather than guessed — a secret you deliberately type into your own prompt is still
sent.

claimedInputRedaction controls it, default true.

Why this was excluded before, and what changed

The stated reason was that an inbox message reaches the log as agent/inbox/spliced before the
pre-step seam runs, so redacting it would leave the request and the log disagreeing.

That reason was too broad. SurfaceEventType is exactly user/message, assistant/message and
tool/result — agent/inbox/spliced is none of them, derives no model message, and builds no
request. The loop appends a user/message surface event for every message the pre-step decision
carries, after the waterfall. So surface event, provider request and audit record all carry the
same placeholder.

The delivery record keeps the original, deliberately. For attacker-supplied text an operator
needs to read what was actually delivered; that record reaches no provider, and this plugin is
read-side with respect to the log, so it is the only durable place that account survives. ADR §30
argues it and a test pins it.

Known gaps, documented rather than papered over

  • dsh-subagent borrows source.kind: 'user' for a prompt the parent model composed, so a
    subagent's opening prompt is exempt. Nothing in source separates it from a human's typing;
    closing it needs an upstream change.
  • The web queue view shows deliveries unredacted while pending. No plugin seam reaches it.

v0.8.1 — the GitHub App token format, and contexts that bypassed the redactor

Choose a tag to compare

@CharlotteN7 CharlotteN7 released this 03 Sep 09:55

The floor could not see a current GitHub App token

GitHub changed installation tokens to ghs_<app id>_<JWT> — around 520 characters carrying
underscores, dots and dashes — and finished the rollout in late June. The rule required 36
unbroken alphanumerics after the prefix, so it stopped at the first underscore:

old ghs_ format          github-token/critical
new ghs_<app id>_<JWT>   *** NOT DETECTED ***

The JWT fallback did not save it either. That pattern opened with \b, and there is no word
boundary between the _ of the prefix and the e of eyJ — both are word characters — so the
token was wholly in the clear, not partly. That anchor was wrong in general, not just here: any
JWT preceded by a letter, digit or underscore was invisible. Both rules now anchor on the base64url
alphabet instead, which admits a strict superset of what \b did.

npm's own advisory pattern was not taken: it matches ghs_report-2026-04-24.summary-eu-west-1.json,
and on a floor rule a false positive costs a denial. Gitleaks carries the same stale pattern, so
this is an ecosystem-wide miss rather than ours alone.

Contexts attached by another plugin were passing through the redactor

additionalContexts were documented as "not scanned". They were worse than that: read from a
downstream listener and forwarded verbatim through this plugin's own redaction arm. Code Mode's
parallel tool calling made that routine traffic rather than an unusual shape.

They are now scanned. Contexts the decision owns are redacted in place; contexts the tool body
deferred cannot be rewritten by any accept arm, so a dirty one is withheld. A UserMessage's
source repeats its text, so both copies are redacted with the same key — redacting only content
would have left the secret in the session log.

Also fixed

  • Result redaction now registers ahead of the waterfall it redacts. A listener mounted before
    this one could restore the raw value into the persisted result. A listener that prepends later
    still can; that limit is documented rather than claimed closed.
  • The audit sink is forced to 0640. It was arriving 0664 under an ordinary umask.
  • dsh-dlp report shows when an ask reached nobody — the state an operator most needs, because
    it is the one where a documented prompt did not happen.
  • The entropy figure was understated in our favour. Re-measured over 611,136 tokens across two
    corpora: a false-positive-free threshold admits nothing under 64–66 characters, not 22. The
    argument against an entropy rule gets stronger; the method is now in the docs so it is reproducible.
  • JsonValue is declared locally, so the package still compiles against the 0.1.2 prerelease
    line where it moved packages.
  • The harness peer range matches the rest of the suite. Three packages shipped three different
    ranges, and npm resolves to the newest any of them admits — installing all four together was
    impossible until this.

Upgrading

New detections mean a call that passed before may now be denied. The additions are the current
GitHub App token format, JWTs after a word character, and secrets inside additionalContexts; each
is a real credential the floor was missing. No configuration changed.