github Dicklesworthstone/destructive_command_guard v0.14.1

5 hours ago

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
    permissionDecision envelope Crush does not read, so a block was silently
    "no opinion" — dcg failed open under Crush (confirmed against Crush's own
    parser). New HookProtocol::Crush (PascalCase event + tool_input, no
    tool_args), CrushHookOutput, and Agent::Crush (CRUSH=1). Warnings
    travel as context with 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 --crush merge and
    remove a matcher: "^bash$" entry in crush.json (CRUSH_GLOBAL_CONFIG,
    XDG_CONFIG_HOME, --project honored), the installers configure it when
    Crush is detected, dcg doctor reports a crush_hook check, and
    docs/crush-integration.md documents the contract.
  • platform.azure_devops pack (#385). az devops, az repos,
    az pipelines, az boards, and az artifacts act 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 artifacts has 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 lfs is a known subcommand with its own destructive rules
    (PR #383).
    git lfs dispatches 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, and lfs uninstall are
    High-severity rules with preview/recover suggestions registered.
  • history.db lives 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.15 history.db
    beside config.toml (honored, never moved), then
    $XDG_STATE_HOME/dcg/history.db / ~/.local/state/dcg/history.db
    (%LOCALAPPDATA%\dcg\history.db on Windows). Directories are created
    0700; dcg doctor gains a history check with a writability probe.

Fixed

  • > ~/new-file outside a VCS worktree is creation, not truncation
    (#390).
    core.filesystem:redirect-truncate-root-home allowed a
    truncating redirect to an absent literal file only when the parent sat
    inside a home-directory git worktree (#337), so echo x > ~/.config/new
    and echo x > ~/.claude/notes.md were 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 .git internals stay blocked. The check-then-open window is unchanged
    from #337; dcg create-new remains 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, so echo x > ~/.zshr{c..c} (a one-word sequence
    expansion in bash and zsh), echo x > ~/.zshrc{,} (zsh MULTIOS writes
    every word), and echo 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 — hit EPIPE, which the println!
    family turns into a panic and panic = "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 to SIG_DFL would kill the hook on
    that same stderr write); instead the hook path, --version, and --help
    write through the new non-panicking emit_stderr!/emit_stdout! helpers
    (src/output/emit.rs), and a panic backstop installed first thing in main
    maps the standard library's broken-pipe print panic on the ordinary CLI
    surface to a clean exit with the new documented EXIT_BROKEN_PIPE (141),
    never a signal death. --version keeps the bare semver as the only stdout
    line and the provenance banner on stderr, where scripts/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.
    The output_*_for_protocol writers now render the verdict into a buffer
    and report whether the single write_all + flush to stdout succeeded; a
    deny, ask, or indeterminate verdict that did not arrive exits with the new
    documented EXIT_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's runner.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 on HookProtocol::undeliverable_block_exit_code
    and in docs/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 rm operand glued to ( no longer qualifies for
    exempt_target_globs (sibling of the #390 follow-up).
    The
    [rules."core.filesystem:rm-*"] exempt_target_globs match (#284) trusts the
    spelled operand, and the tokenizer ends an operand at ( because it is
    subshell syntax — so rm -rf ~/scratch/lo(g|x) was matched as
    ~/scratch/lo, which a scratch glob exempts, while zsh reads lo(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 than max_argument_len, and bare credentials were stored
    byte-for-byte in history.db. src/redaction.rs reinstates pattern
    matching over provider API keys, forge and registry tokens, JWTs,
    Authorization: headers, scheme://user:password@host URLs, private-key
    headers, and password=/secret= assignments, and runs it before argument
    truncation. [general] log_file (log_blocked_command and
    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 in docs/allow-once-usage.md never 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_home treated every absolute parent as
    under a root home, which would have allowed > /etc/new-file; a home with
    no parent now scopes nothing.
  • Allowlist --path grants are scoped to the directory the command really
    runs in, and fail closed without one (#387).
    A paths = [...] entry was
    matched against the hook process's own getcwd(), which has nothing to do
    with the guarded tool call: the host reports that directory in the
    payload's cwd, and a leading cd/pushd can 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 a cd out 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-payload cd, an unknown
    working directory) as "no directory to test against" — every scoped entry
    is then inapplicable. Entries without paths are unaffected.
  • Backquoted substitutions inside expanding heredocs are evaluated (#377),
    and a Codex Bash payload 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 tool Bash on every platform but may run it
    through PowerShell, Git Bash, WSL, or cmd.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 delete no longer matches delete-asset, --help, or
    quoted search text (#380).
    Every verb in the GitHub pack ends in
    (?![\w-]) instead of \b, gh release delete-asset is its own Medium
    rule, --help/-h/gh help <cmd> is a pack safe pattern (quoted tokens
    consumed whole, walk stops at redirection), and gh 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 tripped heredoc.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 account safe suppressed management-group, hierarchy-settings,
    subscription, alias, and lock deletions, and show/list safes 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/-h flag walk.

Changed

  • self_update 1.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

Don't miss a new destructive_command_guard release

NewReleases is sending notifications on new releases.