dcg v0.14.1
Crush support, the closed-pipe / fail-closed hook hardening, the absent-file
redirect carve-out and its obfuscation follow-ups, and the post-0.14.0 pack
fixes (git lfs, Azure, Azure DevOps, gh, heredocs, allowlist path scoping).
Added
- First-class Charm Crush hook support (#388). Crush pipes
{"event":"PreToolUse","tool_name":"bash","tool_input":{"command":…}}
to the hook and reads{"decision":"deny","reason":…}on exit 0. That
payload used to fall through to the Copilot arm and get a flat
permissionDecisionenvelope Crush does not read, so a block was silently
"no opinion" — dcg failed open under Crush (confirmed against Crush's own
parser). NewHookProtocol::Crush(PascalCaseevent+tool_input, no
tool_args),CrushHookOutput, andAgent::Crush(CRUSH=1). Warnings
travel ascontextwith no decision, because in Crush"allow"is an
affirmative pre-approval that skips the user's permission prompt — dcg
never emits it.dcg install --crush/dcg uninstall --crushmerge and
remove amatcher: "^bash$"entry incrush.json(CRUSH_GLOBAL_CONFIG,
XDG_CONFIG_HOME,--projecthonored), the installers configure it when
Crush is detected,dcg doctorreports acrush_hookcheck, and
docs/crush-integration.mddocuments the contract. platform.azure_devopspack (#385).az devops,az repos,
az pipelines,az boards, andaz artifactsact on an organization,
not Azure resources, and were unmatched (or partially overlapped by the
Azure resource safes after #384). GA commands only;az devops invoke
matches on--http-method;az artifactshas no destructive verb and no
rule pretends otherwise; list/show/search and ordinary create/run stay
unmatched.--query, display-name, WIQL, and PR-title text are registered
as non-executed strings.git lfsis a known subcommand with its own destructive rules
(PR #383).git lfsdispatches to the git-lfs helper and never consults
alias.lfs, so the unverifiable-alias catch-all denied every read-only
git lfs ls-files/status/fetch. Those are allowed now, as is
git lfs prune --dry-run(whole token, not quoted text);lfs migrate import|export(history rewrite),lfs prune, andlfs uninstallare
High-severity rules with preview/recover suggestions registered.history.dblives in the XDG state directory and its path is
configurable (#381). History is state, not configuration, so it no
longer defaults into~/.config/dcg(read-only config mounts could not
write it). One resolver decides the path everywhere:DCG_HISTORY_DB,
then[history] database_path, then an existing pre-0.15history.db
besideconfig.toml(honored, never moved), then
$XDG_STATE_HOME/dcg/history.db/~/.local/state/dcg/history.db
(%LOCALAPPDATA%\dcg\history.dbon Windows). Directories are created
0700;dcg doctorgains ahistorycheck with a writability probe.
Fixed
> ~/new-fileoutside a VCS worktree is creation, not truncation
(#390).core.filesystem:redirect-truncate-root-homeallowed a
truncating redirect to an absent literal file only when the parent sat
inside a home-directory git worktree (#337), soecho x > ~/.config/new
andecho x > ~/.claude/notes.mdwere denied while>>to the very same
absent path was allowed. The worktree predicate was attached to the wrong
case: VCS recoverability matters for existing tracked files (still
denied), not for a file that does not exist. The carve-out now applies to
any absent literal target under the home directory whose parent exists;
existing files, symlinks (dangling included), missing parents, dynamic
targets, system paths, parents that resolve outside the home directory,
and.gitinternals stay blocked. The check-then-open window is unchanged
from #337;dcg create-newremains the race-free path.- Brace- and quote-obfuscated redirect targets no longer qualify for the
absent-file carve-out (follow-up to #390). The literal-target check
rejected globs, backslashes, and backticks but not brace expansion or
embedded quotes, soecho x > ~/.zshr{c..c}(a one-word sequence
expansion in bash and zsh),echo x > ~/.zshrc{,}(zsh MULTIOS writes
every word), andecho x > ~/.zsh"rc"(quote removal) were judged by a
path that does not exist while the shell truncated one that does. The
#337 worktree carve-out had the same gap inside repositories; #390 widened
it to the whole home directory. A redirect target is now literal only when
every ASCII character in it is one no supported shell rewrites (letters,
digits,/ . _ - + , @ % : = ~); non-ASCII names stay literal."~/x"is
no longer treated as a home path (~does not expand inside double quotes). - A closed output pipe no longer kills dcg with
SIGABRT(#389).
dcg --version 2>&1 | head -1— and, in hook mode, any stderr diagnostic
written after the host stopped reading — hitEPIPE, which theprintln!
family turns into a panic andpanic = "abort"into a core dump. In hook
mode that also dropped the verdict: a config warning on a closed stderr
aborted the process before the deny JSON was written to a stdout the host
was still reading, which fail-open hosts treat as "proceed".SIGPIPE
deliberately stays ignored (resetting it toSIG_DFLwould kill the hook on
that same stderr write); instead the hook path,--version, and--help
write through the new non-panickingemit_stderr!/emit_stdout!helpers
(src/output/emit.rs), and a panic backstop installed first thing inmain
maps the standard library's broken-pipe print panic on the ordinary CLI
surface to a clean exit with the new documentedEXIT_BROKEN_PIPE(141),
never a signal death.--versionkeeps the bare semver as the only stdout
line and the provenance banner on stderr, wherescripts/perf_baseline.py
reads it. - A blocking verdict that cannot be written to stdout now fails closed
through the exit status (follow-up to #389). Every hook protocol reads
the decision from stdout JSON on exit 0 and treats exit 0 with no JSON as
"proceed", so when the stdout write itself failed (EPIPE: the host closed
the pipe before the verdict was written) a deny quietly became an allow.
Theoutput_*_for_protocolwriters now render the verdict into a buffer
and report whether the singlewrite_all+flushto stdout succeeded; a
deny, ask, or indeterminate verdict that did not arrive exits with the new
documentedEXIT_HOOK_BLOCK(2) and explains itself on stderr. Exit 2 is
the blocking status of the Claude Code contract and of every protocol that
copied it (Gemini CLI, Copilot CLI, Crush, Grok — Crush'srunner.go
verified); Codex, Hermes, and Antigravity log a non-zero exit as a hook
failure and fail open, which is no worse than the silent exit 0. The
per-protocol table lives onHookProtocol::undeliverable_block_exit_code
and indocs/agents.md. An undeliverable allow or warning stays exit 0
(nothing was lost), a delivered verdict keeps exit 0 + JSON, and the
history row is flushed before the fail-closed exit. The CLI surface keeps
EXIT_BROKEN_PIPE(141). - An
rmoperand glued to(no longer qualifies for
exempt_target_globs(sibling of the #390 follow-up). The
[rules."core.filesystem:rm-*"] exempt_target_globsmatch (#284) trusts the
spelled operand, and the tokenizer ends an operand at(because it is
subshell syntax — sorm -rf ~/scratch/lo(g|x)was matched as
~/scratch/lo, which a scratch glob exempts, while zsh readslo(g|x)as
glob alternation and removes~/scratch/log(bash rejects the text as a
syntax error). zsh forbids/inside alternation, so the reachable file is
always a sibling in the same directory; the gap could not escape the
exempted subtree, but it did let a narrow exemption cover a neighbouring
file. The operand is now ineligible whenever the byte after it is(;
subshell grouping ((rm -rf ~/scratch/x),rm -rf ~/scratch/x (echo done)) is unaffected. The redirect exemption and the #390 absent-file
carve-out were audited for the same class (brace expansion, embedded
quotes, alternation, escapes) and already reject every such spelling; the
new tests pin that. redaction_mode = "pattern"performs secret redaction again (#386).
The pattern redactor added in v0.2.8 was deleted by a tracker-sync commit
shortly after v0.2.10 and never restored, so from v0.2.11 onward the
"pattern"mode — the documented default — only truncated quoted
arguments longer thanmax_argument_len, and bare credentials were stored
byte-for-byte inhistory.db.src/redaction.rsreinstates pattern
matching over provider API keys, forge and registry tokens, JWTs,
Authorization:headers,scheme://user:password@hostURLs, private-key
headers, andpassword=/secret=assignments, and runs it before argument
truncation.[general] log_file(log_blocked_commandand
log_budget_skip), which took no redaction config and wrote the raw
command, and the allow-once pending store now go through the same redactor.
Its***example indocs/allow-once-usage.mdnever matched the code and
now reflects the real placeholders.HOME=/no longer grants the absent-file creation carve-out (follow-up
to #390).path_is_new_file_under_hometreated every absolute parent as
under a root home, which would have allowed> /etc/new-file; a home with
no parent now scopes nothing.- Allowlist
--pathgrants are scoped to the directory the command really
runs in, and fail closed without one (#387). Apaths = [...]entry was
matched against the hook process's owngetcwd(), which has nothing to do
with the guarded tool call: the host reports that directory in the
payload'scwd, and a leadingcd/pushdcan move it again. The hook
now resolves the effective cwd from the payload plus any static leading
cd, compares canonical paths (a symlink out of the scope cannot borrow
the grant), lets acdout of the scoped tree revoke the grant but never
extend it, and treats anything it cannot resolve statically (dynamic
targets,popd, subshells, pipes, a nested-payloadcd, an unknown
working directory) as "no directory to test against" — every scoped entry
is then inapplicable. Entries withoutpathsare unaffected. - Backquoted substitutions inside expanding heredocs are evaluated (#377),
and a CodexBashpayload on Windows is judged by its command text
(#379). tree-sitter-bash leaves a backquoted substitution in an
unquoted-delimiter heredoc body as plain content, so`rm -rf ~/x`
never reached the evaluator while$(…)was denied; expanding bodies are
now scanned with here-document escape rules,$(…)spans are evaluated
once, and an unterminated backquote fails closed. Delimiter quoting is
judged from the delimiter word only (a trailing| tee "out"no longer
makes the body look quoted), and backquote bodies carry their post-escape
text. Codex labels its shell toolBashon every platform but may run it
through PowerShell, Git Bash, WSL, orcmd.exe; a command whose POSIX
substitution parse fails is evaluated as PowerShell, anything that parses
as POSIX as the fail-closed union of dialects, so"n"` escapes are
allowed and POSIX-only destructive forms are denied whichever shell runs. gh release deleteno longer matchesdelete-asset,--help, or
quoted search text (#380). Every verb in the GitHub pack ends in
(?![\w-])instead of\b,gh release delete-assetis its own Medium
rule,--help/-h/gh help <cmd>is a pack safe pattern (quoted tokens
consumed whole, walk stops at redirection), andgh search <kind> …is
query data.- Quoted heredoc bodies handed to a non-shell interpreter are not shell
launchers (#382).python3 - <<'EOF'(node/ruby/perl/php too) with a
Markdown fence in the body trippedheredoc.shell:launcher-unverified; a
segment lying entirely inside a quoted body to a proven non-shell
interpreter is withdrawn. Unquoted delimiters and shell receivers keep the
fail-closed treatment. - Azure group-wide safes no longer hide deletions (#384). The blanket
az accountsafe suppressed management-group, hierarchy-settings,
subscription, alias, and lock deletions, andshow/listsafes matched
flag values (az group delete --name prod --yes --query show). The
group-wide safes are gone (unmatched read-only commands are allowed by
default), show/list require a real service token, and help is a real
--help/-hflag walk.
Changed
self_update1.0.0-rc.6 → 1.2.0 (PR #376): only additive API changes for
the backends dcg uses.- Toolchain pinned to
nightly-2026-08-31(fleet-wide unification).
Full changelog: https://github.com/Dicklesworthstone/destructive_command_guard/blob/main/CHANGELOG.md