dsh-dlp

What gets denied

← dsh-dlp docs

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.

Behaviour-changing config paths

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:

Where the tier abstains instead of asking

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.


Arguments that switch off their own confirmation

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.