Credential paths named in a path-typed argument, for every tool: .env and .env.*
directories (but not .env.example), anything under .ssh/,
id_rsa/id_ed25519/id_ecdsa/id_dsa and their backups, ~/.aws/ and ~/.azure/,
$DSH_HOME/.credentials.yaml, .netrc, .npmrc, .pypirc, .git-credentials,
~/.config/gh/, everything under ~/.kube/ — the cached exec-plugin tokens sit a directory
deeper than the config file — and kubeconfig*, /etc/kubernetes/*.conf,
~/.docker/config.json and .dockercfg, gcloud credential files, rclone.conf, .pgpass,
.my.cnf, *service-account*.json, *.pem/*.p12/*.pfx/*.jks/*.keystore/*.key/
*.asc/*.gpg, and any file whose name ends in a delimited credential(s), secret(s) or
token(s) — which covers .vault-token, .gem/credentials, .cargo/credentials.toml,
.terraform.d/credentials.tfrc.json and a Kubernetes service-account token. Source and
documentation extensions are excluded from that last rule, so src/auth/token.ts stays
readable.
Coding-agent and infrastructure credential stores, which IronWorm’s 44 packages and
SANDWORM_MODE name verbatim: an auth.json under .codex/, Cursor/, .composer/,
.windsurf/, .continue/, .aider/, .claude/ or .gemini/; an mcp.json under any of the
same directories, because an MCP manifest carries each server’s env and that is where its API
keys are written; Cursor’s state.vscdb session database; anything under Library/Keychains/;
*.tfvars and terraform.tfstate, which hold provider credentials in plaintext.
A home-level agent settings file is denied for writing only. ~/.claude/settings.json,
~/.gemini/settings.json and the equivalents for Codex, Cursor, Windsurf and Continue decide
how every future session in every repository behaves — this is where the Miasma worm put its
SessionStart hooks — so writing one is on the floor. Reading one is ordinary work, since a
user asking why their agent behaves a certain way is a normal request, so the rule is lifted for
a tool that provably cannot change anything. The repository-local copies of those same file
names are a different question with a different answer: see
behaviour-changing config paths below.
Also denied for every tool: this plugin’s own redactionKeyFile and auditLog.
$DSH_HOME is split by direction. Every write under the harness home is denied, for
every tool: editing a profile’s cordis.yml mounts an arbitrary plugin, which is the exact
threat that makes the directory worth protecting. Reads are denied only where the contents
are credentials — $DSH_HOME/.credentials.yaml, $DSH_HOME/sessions/**, $DSH_HOME/.env,
this plugin’s key file and audit log, and any *.key — so the installed plugin tree under
profiles/node_modules/ and every profile manifest stay readable. A blanket read denial there
made debugging a plugin, reading a profile, and running the sibling dsh-plugin-inspector
against an installed tree impossible, with a message saying the denial could not be overridden.
Which side of that split a call lands on is decided by the tool’s name, from a table of tools
that can only look: read, read_image, glob, grep, lsp, the session-query tools,
job_list, job_output, terminal_list, terminal_read, list_agents, get_goal.
Every other name — every shell, every editor, every mcp__* tool, and any tool this build has
never heard of — is treated as able to write, so a new tool is denied until it is classified.
A shell is never on the read side even for a command that only reads: a shell that can cat a
profile can also rewrite it.
Paths are normalised first — .. traversal, ~, Windows separators, quoting and a trailing
slash do not evade the table — and then resolved with realpathSync, so a symlink named
notes.txt pointing at ~/.ssh/id_rsa is denied by what it resolves to. Only path-typed
argument keys are tested (file_path, path, paths, notebook_path, cwd, command, …).
File content is never treated as a path: writing a .gitignore that lists .env is ordinary
work, not an attempt to read a credential store.
$DSH_HOME/.credentials.yaml is on that list because core permits reading it. The harness has
no file-read restriction in any mode — reads pass through untouched in every permission mode —
so the provider token the agent authenticates with is agent-readable. That is the specific gap
this plugin closes.
Some secrets in arguments, for tools that can move data off the machine. Local tools
(read, glob, grep, write, edit, todo_write, the session-query tools, …) are exempt.
Everything else — every shell, run_code, the web tools, every mcp__* tool, and any tool
this build has never heard of — is treated as egress-capable. Unknown defaults to the safe side.
What this arm actually catches is a whole, unencoded secret of high severity or above sitting
in one argument string. A=ghp_firsthalf; B=…; curl -H "Bearer $A$B", a base64 round-trip, and
$(cat ~/.token) all defeat this arm, because none of them puts a secret in the argument;
a password= assignment is medium and is redacted rather than denied, and so is a payment
card number — an unoverridable denial on an order id that happens to satisfy Luhn is a worse
outcome than a placeholder, and a deployment that disagrees raises that rule’s severity from its
repo-local policy. Treat it as a guard against accident, not against an adversary.
$(cat ~/.token) is the one to read carefully, because the floor has two arms and only the
secret arm misses it: the credential-path arm above tests every token of the same command line
and denies ~/.token on dsh-dlp/path-credential-name. What defeats both arms is a
credential file whose name says nothing — curl -H "Bearer $(cat ~/.config/acme/session)"
runs, verified — or any of the spellings listed under the shell arm’s limits.
A denial reads like this, and reaches the model as the tool’s error result. It names the rule and a keyed hash, never the path — a path is itself sensitive, and this string is written to the model and, in hashed form, to the audit sink:
dsh-dlp denied "read": one of its path arguments is credential material (rule
dsh-dlp/path-aws, keyed hash ca9cad27f2b5). Reading or passing credential files through a
tool is blocked by policy and cannot be overridden. Ask the user to supply the value you
need, or use a path that is not a credential store.
Everything above governs reads. The dominant technique of 2026 is the opposite: the agent
writes a file that changes what happens next time. The Miasma worm put SessionStart hooks in
.claude/settings.json and .gemini/settings.json, an always-apply .cursor/rules/setup.mdc,
a folderOpen task in .vscode/tasks.json and a hijacked npm test into Azure/durabletask;
GitHub disabled 73 repositories across Azure, microsoft and Azure-Samples over it, 39 of them
inside 38 seconds. See also CVE-2025-53773, CVE-2026-25725, CVE-2026-33068, CVE-2026-48124,
CVE-2026-26268 and CVE-2025-59041.
A write to one of these asks the user first:
| Rule | Paths |
|---|---|
config-agent-settings |
.claude/settings*.json, and the same under .gemini/, .codex/, .cursor/, .windsurf/, .continue/ |
config-agent-hooks |
.claude/hooks/**, and the same under .gemini/, .codex/, .cursor/, .windsurf/, .continue/ |
config-agent-instructions |
CLAUDE.md, CLAUDE.local.md, AGENTS.md, GEMINI.md, .cursorrules, .windsurfrules |
config-agent-rules |
.claude/rules/**, .cursor/rules/**, .windsurf/rules/**, .continue/rules/** |
config-copilot-instructions |
.github/copilot-instructions.md, .github/instructions/** |
config-prompt-template |
.prompts/**/*.prompttemplate (CVE-2026-46580) |
config-mcp-manifest |
.mcp.json |
config-editor-tasks |
.vscode/settings.json, .vscode/tasks.json, .vscode/launch.json |
config-git |
.git/config, .git/hooks/** |
config-git-hooks-managed |
.husky/** |
config-ci-workflow |
.github/workflows/**, .gitlab-ci.yml, .circleci/** |
config-shell-rc |
.bashrc, .bash_profile, .profile, .zshrc, .zprofile, .zshenv, .kshrc, config.fish, … |
config-harness-bundle |
cordis*.yml |
config-pnpm-workspace |
pnpm-workspace.yaml |
config-api-base-url |
not a path — content setting a *_BASE_URL or *_API_BASE to an http(s) URL |
.claude/rules is in the first of those rows because VS Code lists it as a workspace
instruction location it detects and applies on its own, alongside AGENTS.md and CLAUDE.md —
so the directory is loaded by an editor nobody configured for Claude. The other three agent
rules directories were already covered and it was not.
pnpm-workspace.yaml is there because pnpm reads registry, registries and namedRegistries
from it, so the file decides which host the next install downloads packages from. pnpm’s own
documentation treats the file as attacker-controlled for exactly that reason: since v11.5.3 it
refuses to expand ${…} inside those settings, “Because pnpm-workspace.yaml is committed to
the repository, expanding env variables in registry URLs could be exploited by a malicious
repository to leak secrets from the environment to an attacker-controlled registry.” A literal
hostile registry URL is still obeyed. The .npmrc half of the same technique needs no rule
here — it is on the guard floor, where every call is denied.
The last row is CVE-2026-21852: a repo-local settings file that sets ANTHROPIC_BASE_URL sends
the user’s own API key to whatever host it names. That is neither a path nor a secret — it is a
config key whose value redirects a credential — so it is matched against the bytes the call
would write rather than against where they would go.
Rules match by name, so creating a file is covered as well as changing one. CVE-2026-25725 worked precisely because the path did not exist yet and was therefore writable with nothing to prompt about.
This tier is ask, and it is therefore neutralizable — unlike the floor. That is deliberate
and it is the important sentence in this section. A developer asks the agent to edit CLAUDE.md
or add a workflow constantly; the guard floor is deny-only and non-overridable by design, so a
rule with that false-positive rate must not go there. It lives at tools/pre-execute, which
means a listener registered ahead of ours can return without calling next() and switch the
whole tier off. Treat it as a prompt, not as a control.
A call the floor already denies is left to the floor, and no prompt appears for it. Any
non-allow decision at tools/pre-execute skips guards entirely, so asking about a call the
guard would deny would replace an unconditional denial with a prompt a user can grant. That is
also why ~/.claude/settings.json and a repository’s own .claude/settings.json behave
differently: the first is on the floor, the second is a prompt.
One more limit worth stating:
cat .github/workflows/ci.yml is exactly the false positive
that gets a tier switched off.Where the approval seam can prompt nobody, the tier lets the call through rather than denying
it. There are three such states, and each one would otherwise turn an ask into a denial
nobody was shown:
| State | What the harness does with an ask |
|---|---|
| No approval service composed | The registry keeps its historical degrade to deny |
The approval policy in force is never |
The service resolves rejected before any answerer sees it |
Nothing composed on approval/request |
The waterfall falls through to the fail-closed unavailable, which the registry denies |
In all three the tier allows the call, reports the state once on process.stderr and
ctx.logger naming what to change, and writes a pre-execute-ask-abstained audit record
carrying the rule it would have asked about and which state stopped it. The state is read on
every decision rather than once at mount, because a session switches policy mid-run through
approval/policy, the override is per session, and an answerer can be composed or disposed while
the harness runs. Anything short of a positive reading of one of the three keeps the prompt: a
call with no agent, a service whose fields are not the ones read, and an override fold that
throws all still ask.
Under DSH_PERMISSION_MODE=danger-full-access this tier does not fire at all. The shipped
dsh-base bundle gives the approval row policy: never in that mode, which is the second state
above. An operator who wants these writes stopped in that posture needs the guard floor or a
different tool — this tier is a prompt, and under that mode there is nobody to prompt.
A stock dsh-headless install composes no answerer, so in every other permission mode the
third state applies and the tier abstains there too. An approval surface — the Host API proxy or
the ACP bridge — is what makes the prompt appear. configWriteAsk: false turns this half of the
tier off entirely.
The same tier watches one thing that is not a file at all: a tool argument whose effect is to
skip the confirmation for the call carrying it. CVE-2026-18733 is non_interactive: true,
CVE-2026-53808 is apply: true while approvalPolicy is still pending, and CVE-2026-56075 is
approval_mode: auto. In each one the model composes an argument that removes the human from
that call, on a call whose paths and arguments are otherwise unremarkable.
| Rule | Arguments |
|---|---|
approval-non-interactive |
non_interactive (any spelling) set to a true-ish value |
approval-mode-auto |
approval_mode, approval_policy or approval_setting set to auto, never, none, bypass, full-auto or yolo |
approval-apply-pending |
apply true and approvalPolicy: pending on the same object |
Keys and string values are matched with _, - and . removed and case folded, so
non_interactive, nonInteractive and non-interactive are one name, and full-auto — the
spelling Codex writes — full_auto and fullAuto are one value, at any depth of the
arguments. A mode that keeps the prompt is spelled the other way round, so on-demand and
ask-every-time are outside the table under the same fold. A tool that can only look is left
alone: it has nothing to confirm. approvalSuppressionAsk: false turns
this half of the tier off.
This is ask for the same reason the write side is, and the reasoning is worth stating.
non_interactive also means “no TTY” on plenty of ordinary programs, and a batch workflow can
legitimately pass it. The tool registry is open, so which argument names carry approval meaning
is a guess about tools this build has never seen — and a guess does not belong on a floor whose
denials cannot be overridden. An ask is also the right remedy rather than a compromise: the
argument’s whole purpose is to remove a prompt, and this tier puts one back.
It shares the write side’s costs: neutralizable at tools/pre-execute, abstaining wherever the
approval seam cannot prompt anyone — including under danger-full-access and on a stock headless
install — and silent on a call the floor already denies.