
Dcg
- 169 installs
- 5.6k repo stars
- Updated August 4, 2026
- dicklesworthstone/destructive_command_guard
Intercept or block rm -rf, force pushes, credential wipes, and other irreversible shell commands before an agent or developer executes them in local or CI environments.
About
The destructive_command_guard (dcg) skill from dicklesworthstone/destructive_command_guard teaches Claude Code to recognize and refuse dangerous terminal commands. It acts as an appsec safety layer for agentic and CLI workflows where a single mistyped rm or force push could destroy data.
- Blocks high-risk destructive shell patterns
- Protects repos during agent-driven command runs
- Reduces accidental data loss and credential exposure
- Lightweight guard for CLI and coding agents
- Pairs with automated dev workflows safely
Dcg by the numbers
- 169 all-time installs (skills.sh)
- Ranked #842 of 2,203 Security skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dicklesworthstone/destructive_command_guard --skill dcgAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 169 |
|---|---|
| repo stars | ★ 5.6k |
| Last updated | August 4, 2026 |
| Repository | dicklesworthstone/destructive_command_guard ↗ |
What it does
Intercept or block rm -rf, force pushes, credential wipes, and other irreversible shell commands before an agent or developer executes them in local or CI environments.
Files
DCG — Destructive Command Guard
A high-performance Claude Code hook that intercepts and blocks destructive commands before they execute. Written in Rust with SIMD-accelerated filtering for sub-millisecond latency.
Why This Exists
AI coding agents are powerful but fallible. They can accidentally run destructive commands:
- "Let me clean up the build artifacts" →
rm -rf ./src(typo) - "I'll reset to the last commit" →
git reset --hard(destroys uncommitted changes) - "Let me fix the merge conflict" →
git checkout -- .(discards all modifications) - "I'll clean up untracked files" →
git clean -fd(permanently deletes untracked files)
DCG intercepts dangerous commands before execution and blocks them with a clear explanation, giving you a chance to stash your changes first.
Critical Design Principles
1. Whitelist-First Architecture
Safe patterns are checked before destructive patterns. This ensures explicitly safe commands are never accidentally blocked:
git checkout -b feature → Matches SAFE "checkout-new-branch" → ALLOW
git checkout -- file.txt → No safe match, matches DESTRUCTIVE → DENY2. Fail-Safe Defaults (Default-Allow)
Unrecognized commands are allowed by default. This ensures:
- The hook never breaks legitimate workflows
- Only known dangerous patterns are blocked
- New git commands work until explicitly categorized
3. Zero False Negatives Philosophy
The pattern set prioritizes never allowing dangerous commands over avoiding false positives. A few extra prompts for manual confirmation are acceptable; lost work is not.
What It Blocks
Git Commands That Destroy Uncommitted Work
| Command | Reason |
|---|---|
git reset --hard | Destroys uncommitted changes |
git reset --merge | Destroys uncommitted changes |
git checkout -- <file> | Discards file modifications |
git restore <file> (without --staged) | Discards uncommitted changes |
git clean -f | Permanently deletes untracked files |
Git Commands That Destroy Remote History
| Command | Reason |
|---|---|
git push --force / -f | Overwrites remote commits |
git branch -D | Force-deletes without merge check |
Git Commands That Destroy Stashed Work
| Command | Reason |
|---|---|
git stash drop | Permanently deletes a stash |
git stash clear | Permanently deletes all stashes |
Filesystem Commands
| Command | Reason |
|---|---|
rm -rf (outside /tmp, /var/tmp, $TMPDIR) | Recursive deletion is dangerous |
What It ALLOWS
Safe operations pass through silently:
Always Safe Git Operations
git status, git log, git diff, git add, git commit, git push, git pull, git fetch, git branch -d (safe delete with merge check), git stash, git stash pop, git stash list
Explicitly Safe Patterns
| Pattern | Why Safe |
|---|---|
git checkout -b <branch> | Creating new branches |
git checkout --orphan <branch> | Creating orphan branches |
git restore --staged <file> | Unstaging only, doesn't touch working tree |
git restore -S <file> | Short flag for staged |
git clean -n / --dry-run | Preview mode, no actual deletion |
rm -rf /tmp/* | Temp directories are ephemeral |
rm -rf $TMPDIR/* | Shell variable forms |
Safe Alternative: --force-with-lease
git push --force-with-lease # ALLOWED - refuses if remote has unseen commits
git push --force # BLOCKED - can overwrite others' workModular Pack System
DCG uses a modular "pack" system to organize patterns by category:
Core Packs (Always Enabled)
| Pack | Description |
|---|---|
core.git | Destructive git commands |
core.filesystem | Dangerous rm -rf outside temp |
Database Packs
| Pack | Description |
|---|---|
database.postgresql | DROP/TRUNCATE in PostgreSQL |
database.mysql | DROP/TRUNCATE in MySQL/MariaDB |
database.mongodb | dropDatabase, drop() |
database.redis | FLUSHALL/FLUSHDB |
database.sqlite | DROP in SQLite |
Container Packs
| Pack | Description |
|---|---|
containers.docker | docker system prune, docker rm -f |
containers.compose | docker-compose down --volumes |
containers.podman | podman system prune |
Kubernetes Packs
| Pack | Description |
|---|---|
kubernetes.kubectl | kubectl delete namespace |
kubernetes.helm | helm uninstall |
kubernetes.kustomize | kustomize delete patterns |
Cloud Provider Packs
| Pack | Description |
|---|---|
cloud.aws | Destructive AWS CLI commands |
cloud.gcp | Destructive gcloud commands |
cloud.azure | Destructive az commands |
Infrastructure Packs
| Pack | Description |
|---|---|
infrastructure.terraform | terraform destroy |
infrastructure.ansible | Dangerous ansible patterns |
infrastructure.pulumi | pulumi destroy |
System Packs
| Pack | Description |
|---|---|
system.disk | dd, mkfs, fdisk operations |
system.permissions | Dangerous chmod/chown patterns |
system.services | systemctl stop/disable patterns |
Other Packs
| Pack | Description |
|---|---|
strict_git | Extra paranoid git protections |
package_managers | npm unpublish, cargo yank |
Configuring Packs
# ~/.config/dcg/config.toml
[packs]
enabled = [
"database.postgresql",
"containers.docker",
"kubernetes", # Enables all kubernetes sub-packs
]Environment Variables
| Variable | Description |
|---|---|
DCG_PACKS="containers.docker,kubernetes" | Enable packs (comma-separated) |
DCG_DISABLE="kubernetes.helm" | Disable packs/sub-packs |
DCG_VERBOSE=1 | Verbose output |
| `DCG_COLOR=auto\ | always\ |
DCG_BYPASS=1 | Bypass DCG entirely (escape hatch) |
Installation
Quick Install (Recommended)
curl -fsSL "https://raw.githubusercontent.com/Dicklesworthstone/destructive_command_guard/main/install.sh?$(date +%s)" | bash
# Easy mode: auto-update PATH
curl -fsSL "https://raw.githubusercontent.com/Dicklesworthstone/destructive_command_guard/main/install.sh?$(date +%s)" | bash -s -- --easy-mode
# System-wide (requires sudo)
curl -fsSL "https://raw.githubusercontent.com/Dicklesworthstone/destructive_command_guard/main/install.sh?$(date +%s)" | sudo bash -s -- --systemFrom Source (Requires Rust Nightly)
cargo +nightly install --git https://github.com/Dicklesworthstone/destructive_command_guardPrebuilt Binaries
Available for: Linux x86_64, Linux ARM64, macOS Intel, macOS Apple Silicon, Windows
Claude Code Configuration
Add to ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "dcg"
}
]
}
]
}
}Important: Restart Claude Code after adding the hook.
How It Works
Processing Pipeline
┌─────────────────────────────────────────────────────────────────┐
│ Claude Code │
│ Agent executes `rm -rf ./build` │
└─────────────────────┬───────────────────────────────────────────┘
│
▼ PreToolUse hook (stdin: JSON)
┌─────────────────────────────────────────────────────────────────┐
│ dcg │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Parse │───▶│ Normalize │───▶│ Quick Reject │ │
│ │ JSON │ │ Command │ │ Filter │ │
│ └──────────────┘ └──────────────┘ └──────┬───────┘ │
│ │ │
│ ┌───────────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Pattern Matching │ │
│ │ 1. Check SAFE_PATTERNS (whitelist) ──▶ Allow if match │ │
│ │ 2. Check DESTRUCTIVE_PATTERNS ──────▶ Deny if match │ │
│ │ 3. No match ────────────────────────▶ Allow (default) │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────┬───────────────────────────────────────────┘
│
▼ stdout: JSON (deny) or empty (allow)Stage 1: JSON Parsing
- Reads hook input from stdin
- Validates Claude Code's
PreToolUseformat - Non-Bash tools immediately allowed
Stage 2: Command Normalization
- Strips absolute paths:
/usr/bin/git status→git status - Preserves argument paths
Stage 3: Quick Rejection Filter
- SIMD-accelerated substring search for "git" or "rm"
- Commands without these bypass regex entirely (99%+ of commands)
Stage 4: Pattern Matching
- Safe patterns checked first (short-circuit on match → allow)
- Destructive patterns checked second (match → deny)
- No match → default allow
Exit Codes
| Code | Meaning |
|---|---|
0 | Command is safe, proceed |
2 | Command is blocked, do not execute |
CLI Usage
Test commands manually:
# Show version with build metadata
dcg --version
# Test a command
echo '{"tool_name":"Bash","tool_input":{"command":"git reset --hard"}}' | dcgExample Block Message
════════════════════════════════════════════════════════════════════════
BLOCKED dcg
────────────────────────────────────────────────────────────────────────
Reason: git reset --hard destroys uncommitted changes. Use 'git stash' first.
Command: git reset --hard HEAD~1
Tip: If you need to run this command, execute it manually in a terminal.
Consider using 'git stash' first to save your changes.
════════════════════════════════════════════════════════════════════════Contextual Suggestions
| Command Type | Suggestion |
|---|---|
git reset, git checkout -- | "Consider using 'git stash' first" |
git clean | "Use 'git clean -n' first to preview" |
git push --force | "Consider using '--force-with-lease'" |
rm -rf | "Verify the path carefully before running manually" |
Edge Cases Handled
Path Normalization
/usr/bin/git reset --hard # Blocked
/usr/local/bin/git checkout -- . # Blocked
/bin/rm -rf /home/user # BlockedFlag Ordering Variants
rm -rf /path # Combined flags
rm -fr /path # Reversed order
rm -r -f /path # Separate flags
rm --recursive --force /path # Long flagsAll variants are handled.
Shell Variable Expansion
rm -rf $TMPDIR/build # Allowed (temp)
rm -rf ${TMPDIR}/build # Allowed
rm -rf "$TMPDIR/build" # Allowed
rm -rf "${TMPDIR:-/tmp}/build" # AllowedStaged vs Worktree Restore
git restore --staged file.txt # Allowed (unstaging only)
git restore -S file.txt # Allowed (short flag)
git restore file.txt # BLOCKED (discards changes)
git restore --worktree file.txt # BLOCKED (explicit worktree)
git restore -S -W file.txt # BLOCKED (includes worktree)Performance Optimizations
DCG is designed for zero perceived latency:
| Optimization | Technique |
|---|---|
| Lazy Static | Regex patterns compiled once via LazyLock |
| SIMD Quick Reject | memchr crate for CPU vector instructions |
| Early Exit | Safe match returns immediately |
| Zero-Copy JSON | serde_json operates on input buffer |
| Zero-Allocation | Cow<str> for path normalization |
| Release Profile | opt-level="z", LTO, single codegen unit |
Result: Sub-millisecond execution for typical commands.
Pattern Counts
| Type | Count |
|---|---|
| Safe patterns (whitelist) | 34 |
| Destructive patterns (blacklist) | 16 |
Security Considerations
What DCG Protects Against
- Accidental data loss from
git checkout --orgit reset --hard - Remote history destruction from force pushes
- Stash loss from
git stash drop/clear - Filesystem accidents from
rm -rfoutside temp directories
What DCG Does NOT Protect Against
- Malicious actors (can bypass the hook)
- Non-Bash commands (Python/JavaScript file writes, API calls)
- Committed but unpushed work
- Commands inside scripts (
./deploy.shcontents not inspected)
Threat Model
DCG assumes the AI agent is well-intentioned but fallible. It catches honest mistakes, not adversarial attacks.
Troubleshooting
Hook not blocking commands
1. Verify ~/.claude/settings.json has hook configuration 2. Restart Claude Code 3. Test manually: echo '{"tool_name":"Bash","tool_input":{"command":"git reset --hard"}}' | dcg
Hook blocking safe commands
1. Check if there's an edge case not covered 2. File a GitHub issue 3. Temporary bypass: DCG_BYPASS=1 or run command manually
FAQ
Q: Why block `git branch -D` but allow `git branch -d`?
Lowercase -d only deletes branches fully merged. Uppercase -D force-deletes regardless of merge status, potentially losing commits.
Q: Why is `git push --force-with-lease` allowed?
Force-with-lease refuses to push if the remote has commits you haven't seen, preventing accidental overwrites.
Q: Why block all `rm -rf` outside temp directories?
Recursive forced deletion is extremely dangerous. A typo or wrong variable can delete critical files. Temp directories are designed to be ephemeral.
Q: What if I really need to run a blocked command?
DCG instructs the agent to ask for permission. Run the command manually in a separate terminal after making a conscious decision.
Integration with Flywheel
| Tool | Integration |
|---|---|
| Claude Code | Native PreToolUse hook |
| Agent Mail | Agents can report blocked commands to coordinator |
| BV | Flag tasks that repeatedly trigger DCG |
| CASS | Search DCG block patterns across sessions |
| RU | DCG protects agent-sweep from destructive commits |
{"pid":3374580,"started_at":"2026-02-09T20:55:29.787315322-05:00","hostname":"threadripperje"}
# SQLite databases
*.db
*.db?*
*.db-journal
*.db-wal
*.db-shm
# Daemon runtime files
daemon.lock
daemon.log
daemon.pid
bd.sock
sync-state.json
last-touched
# Local version tracking (prevents upgrade notification spam after git ops)
.local_version
# Legacy database files
db.sqlite
bd.db
# Worktree redirect file (contains relative path to main repo's .beads/)
# Must not be committed as paths would be wrong in other clones
redirect
# Merge artifacts (temporary files from 3-way merge)
beads.base.jsonl
beads.base.meta.json
beads.left.jsonl
beads.left.meta.json
beads.right.jsonl
beads.right.meta.json
# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here.
# They would override fork protection in .git/info/exclude, allowing
# contributors to accidentally commit upstream issue databases.
# The JSONL files (issues.jsonl, interactions.jsonl) and config files
# are tracked by git by default since no pattern above ignores them.
# Local history backups
.br_history/
# bv (beads viewer) lock file
.bv.lock
# Beads Configuration File
# This file configures default behavior for all bd commands in this repository
# All settings can also be set via environment variables (BD_* prefix)
# or overridden with command-line flags
# Issue prefix for this repository (used by bd init)
# If not set, bd init will auto-detect from directory name
# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc.
# issue-prefix: ""
# Use no-db mode: load from JSONL, no SQLite, write back after each command
# When true, bd will use .beads/issues.jsonl as the source of truth
# instead of SQLite database
# no-db: false
# Disable daemon for RPC communication (forces direct database access)
# no-daemon: false
# Disable auto-flush of database to JSONL after mutations
# no-auto-flush: false
# Disable auto-import from JSONL when it's newer than database
# no-auto-import: false
# Enable JSON output by default
# json: false
# Default actor for audit trails (overridden by BD_ACTOR or --actor)
# actor: ""
# Path to database (overridden by BEADS_DB or --db)
# db: ""
# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON)
# auto-start-daemon: true
# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE)
# flush-debounce: "5s"
# Git branch for beads commits (bd sync will commit to this branch)
# IMPORTANT: Set this for team projects so all clones use the same sync branch.
# This setting persists across clones (unlike database config which is gitignored).
# Can also use BEADS_SYNC_BRANCH env var for local override.
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
sync-branch: "main"
# Multi-repo configuration (experimental - bd-307)
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
# repos:
# primary: "." # Primary repo (where this database lives)
# additional: # Additional repos to hydrate from (read-only)
# - ~/beads-planning # Personal planning repo
# - ~/work-planning # Work planning repo
# Integration settings (access with 'bd config get/set')
# These are stored in the database, not in this file:
# - jira.url
# - jira.project
# - linear.url
# - linear.api-key
# - github.org
# - github.repo{
"database": "beads.db",
"jsonl_export": "issues.jsonl"
}Beads - AI-Native Issue Tracking
Welcome to Beads! This repository uses Beads for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code.
What is Beads?
Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git.
Learn more: github.com/steveyegge/beads
Quick Start
Essential Commands
# Create new issues
bd create "Add user authentication"
# View all issues
bd list
# View issue details
bd show <issue-id>
# Update issue status
bd update <issue-id> --status in_progress
bd update <issue-id> --status done
# Sync with git remote
bd syncWorking with Issues
Issues in Beads are:
- Git-native: Stored in
.beads/issues.jsonland synced like code - AI-friendly: CLI-first design works perfectly with AI coding agents
- Branch-aware: Issues can follow your branch workflow
- Always in sync: Auto-syncs with your commits
Why Beads?
✨ AI-Native Design
- Built specifically for AI-assisted development workflows
- CLI-first interface works seamlessly with AI coding agents
- No context switching to web UIs
🚀 Developer Focused
- Issues live in your repo, right next to your code
- Works offline, syncs when you push
- Fast, lightweight, and stays out of your way
🔧 Git Integration
- Automatic sync with git commits
- Branch-aware issue tracking
- Intelligent JSONL merge resolution
Get Started with Beads
Try Beads in your own projects:
# Install Beads
curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash
# Initialize in your repo
bd init
# Create your first issue
bd create "Try out Beads"Learn More
- Documentation: github.com/steveyegge/beads/docs
- Quick Start Guide: Run
bd quickstart - Examples: github.com/steveyegge/beads/examples
---
Beads: Issue tracking that moves at the speed of thought ⚡
# cargo-nextest configuration for git_safety_guard
# https://nexte.st/book/configuration.html
# =============================================================================
# Default Profile - Development
# =============================================================================
[profile.default]
# Fail fast during local development
fail-fast = true
# Run tests in parallel (default: number of CPUs)
test-threads = "num-cpus"
# Output format for local development
status-level = "pass"
final-status-level = "fail"
# Retry configuration
retries = 0
# Slow test thresholds
slow-timeout = { period = "30s", terminate-after = 2 }
# =============================================================================
# CI Profile - Continuous Integration
# =============================================================================
[profile.ci]
# Don't fail fast in CI - run all tests to get complete results
fail-fast = false
# Use all CPUs
test-threads = "num-cpus"
# Verbose output for CI logs
status-level = "all"
final-status-level = "all"
# Retry flaky tests once in CI
retries = 1
# Longer timeout for CI (may be slower)
slow-timeout = { period = "60s", terminate-after = 2 }
# JUnit XML output for CI integration
[profile.ci.junit]
# Output path for JUnit XML report (relative to store dir)
path = "junit.xml"
# Report name shown in CI systems
report-name = "git-safety-guard-test-results"
# Store output on failure for debugging
store-success-output = false
store-failure-output = true
# =============================================================================
# E2E Profile - End-to-end tests (sequential)
# =============================================================================
[profile.e2e]
# Don't fail fast - run all E2E tests
fail-fast = false
# E2E tests often need sequential execution
test-threads = 1
status-level = "all"
final-status-level = "all"
retries = 1
# E2E tests may take longer
slow-timeout = { period = "60s", terminate-after = 2 }
[profile.e2e.junit]
path = "junit.xml"
report-name = "git-safety-guard-e2e-test-results"
store-success-output = false
store-failure-output = true
# =============================================================================
# Coverage Profile
# =============================================================================
[profile.ci-coverage]
fail-fast = false
test-threads = "num-cpus"
status-level = "all"
final-status-level = "all"
retries = 0
slow-timeout = { period = "120s", terminate-after = 2 }
[profile.ci-coverage.junit]
path = "junit.xml"
report-name = "git-safety-guard-coverage-test-results"
store-success-output = false
store-failure-output = true
# Use bd merge for beads JSONL files
.beads/issues.jsonl merge=beads
# Dependabot configuration for DCG
# Docs: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates
version: 2
updates:
# Cargo (Rust) dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "America/New_York"
open-pull-requests-limit: 5
commit-message:
prefix: "deps"
include: "scope"
labels:
- "dependencies"
- "rust"
groups:
# Group minor/patch updates together
rust-minor-patch:
patterns:
- "*"
update-types:
- "minor"
- "patch"
exclude-patterns:
- "serde*"
# Keep serde updates separate (widely used, review carefully)
serde:
patterns:
- "serde*"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "America/New_York"
open-pull-requests-limit: 3
commit-message:
prefix: "ci"
include: "scope"
labels:
- "dependencies"
- "github-actions"
groups:
actions:
patterns:
- "*"
blank_issues_enabled: true
contact_links:
- name: Documentation
url: https://github.com/Dicklesworthstone/destructive_command_guard#readme
about: Check the README and docs before opening an issue
- name: Discussions
url: https://github.com/Dicklesworthstone/destructive_command_guard/discussions
about: Ask questions and discuss ideas
name: False Negative Report
description: A dangerous command is NOT being blocked when it should be
title: "[False Negative] "
labels: ["false-negative", "security"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve dcg's coverage! Security gaps are our top priority.
**Before submitting:**
- Run `dcg test '<your-command>'` to verify it's not being blocked
- Check if the relevant pack is enabled in your config
- type: input
id: command
attributes:
label: Dangerous Command
description: The exact command that should be blocked but isn't
placeholder: "e.g., rm -rf /var/log/*"
validations:
required: true
- type: textarea
id: why-dangerous
attributes:
label: Why is this dangerous?
description: Explain what damage this command could cause
placeholder: |
This command could...
- Delete critical system logs
- Cause data loss
- Break the system in irreversible ways
validations:
required: true
- type: textarea
id: test-output
attributes:
label: Output of `dcg test`
description: Paste the output of `dcg test '<your-command>'`
render: shell
validations:
required: true
- type: dropdown
id: category
attributes:
label: Category
description: What category does this command fall under?
options:
- Git
- Filesystem (rm, mv, etc.)
- Database (SQL, Redis, etc.)
- Kubernetes/Docker
- Cloud (AWS, GCP, Azure)
- CI/CD
- Other
validations:
required: true
- type: input
id: suggested-pack
attributes:
label: Suggested Pack
description: Which pack should cover this? (if known)
placeholder: "e.g., core.filesystem, database.postgresql"
- type: textarea
id: context
attributes:
label: Additional Context
description: Any other information (related commands, attack scenarios, etc.)
- type: input
id: version
attributes:
label: dcg Version
description: Output of `dcg --version`
placeholder: "dcg 0.2.0 (abc1234)"
validations:
required: true
- type: textarea
id: config
attributes:
label: Enabled Packs
description: Output of `dcg packs --enabled`
render: shell
name: False Positive Report
description: A legitimate command is being incorrectly blocked by dcg
title: "[False Positive] "
labels: ["false-positive", "bug"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve dcg! False positives are frustrating - let's fix them.
**Before submitting:**
- Run `dcg explain '<your-command>'` to get the full decision trace
- Make sure you're on the latest version (`dcg --version`)
- type: input
id: command
attributes:
label: Blocked Command
description: The exact command that was incorrectly blocked
placeholder: "e.g., git reset --soft HEAD~1"
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: Why should this command be allowed?
placeholder: |
This command is safe because...
Unlike `git reset --hard`, `--soft` preserves changes in the working directory.
validations:
required: true
- type: textarea
id: explain-output
attributes:
label: Output of `dcg explain`
description: Paste the full output of `dcg explain '<your-command>'`
render: shell
validations:
required: true
- type: input
id: rule-id
attributes:
label: Rule ID
description: The pack:pattern ID shown in the block message
placeholder: "e.g., core.git:reset-hard"
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional Context
description: Any other information that might help (use case, environment, etc.)
- type: input
id: version
attributes:
label: dcg Version
description: Output of `dcg --version`
placeholder: "dcg 0.2.0 (abc1234)"
validations:
required: true
- type: checkboxes
id: workaround
attributes:
label: Workaround
description: Have you found a workaround?
options:
- label: I am using `dcg allow-once` as a temporary bypass
- label: I have added this to my project allowlist
- label: No workaround found
name: New Pack Request
description: Request a new security pack for a tool or category not currently covered
title: "[Pack Request] "
labels: ["pack-request", "enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a new pack! We're always looking to expand coverage.
See the [pack expansion guide](../../docs/pack-expansion-guide.md) for our pack development process.
- type: input
id: tool
attributes:
label: Tool/Category Name
description: What tool or category should be covered?
placeholder: "e.g., Terraform, Ansible, Redis, Cloudflare"
validations:
required: true
- type: textarea
id: destructive-commands
attributes:
label: Destructive Commands
description: List the dangerous commands that should be blocked
placeholder: |
- `terraform destroy` - destroys all managed infrastructure
- `terraform apply -auto-approve` - applies changes without confirmation
- `terraform state rm` - removes resources from state (orphans them)
validations:
required: true
- type: textarea
id: safe-commands
attributes:
label: Safe Commands (Whitelist)
description: List commands that look similar but are safe
placeholder: |
- `terraform plan` - just shows what would change
- `terraform state list` - read-only listing
- `terraform validate` - syntax check only
- type: dropdown
id: priority
attributes:
label: Impact/Priority
description: How critical is this coverage?
options:
- Critical - Production systems at risk
- High - Significant data loss potential
- Medium - Useful but workarounds exist
- Low - Nice to have
validations:
required: true
- type: textarea
id: use-case
attributes:
label: Use Case
description: How do you use this tool with AI coding agents?
placeholder: |
I use Claude/GPT to help manage my infrastructure...
The AI sometimes suggests running dangerous commands like...
- type: checkboxes
id: contribution
attributes:
label: Contribution
description: Would you be willing to help?
options:
- label: I can help test the pack once implemented
- label: I can contribute the pack implementation
- label: I can provide more example commands
- type: textarea
id: docs
attributes:
label: Documentation Links
description: Links to official docs about these commands
name: Notify ACFS checksum monitor
on:
push:
branches: [main]
paths:
- 'install.sh'
- 'scripts/install.sh'
release:
types: [published]
workflow_dispatch:
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Dispatch to ACFS
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.ACFS_REPO_DISPATCH_TOKEN }}
repository: Dicklesworthstone/agentic_coding_flywheel_setup
event-type: upstream-changed
client-payload: '{"repo":"${{ github.repository }}","ref":"${{ github.ref }}","sha":"${{ github.sha }}","event":"${{ github.event_name }}"}'
name: bench
# Benchmark enforcement for performance regressions
#
# This workflow:
# 1. Runs criterion benchmarks on PRs and pushes to main
# 2. Compares results against baseline (when available)
# 3. Fails on significant regressions
# 4. Uploads benchmark results as artifacts
#
# Performance budgets are defined in src/perf.rs
on:
pull_request:
paths:
- 'src/**'
- 'benches/**'
- 'Cargo.toml'
- 'Cargo.lock'
push:
branches: [main]
paths:
- 'src/**'
- 'benches/**'
- 'Cargo.toml'
- 'Cargo.lock'
# Allow manual trigger
workflow_dispatch:
# Run on schedule for baseline tracking
schedule:
- cron: '0 6 * * 1' # Weekly on Monday at 6am UTC
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
- name: Cache cargo registry and target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-bench-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-bench-
- name: Cache benchmark baseline
uses: actions/cache@v5
with:
path: target/criterion
key: ${{ runner.os }}-criterion-baseline-${{ github.ref_name }}
restore-keys: |
${{ runner.os }}-criterion-baseline-main
${{ runner.os }}-criterion-baseline-
- name: Build benchmarks
run: cargo build --release --benches
- name: Run benchmarks
run: |
# Run benchmarks and capture output
cargo bench --bench heredoc_perf -- --noplot 2>&1 | tee bench-output.txt
cargo bench --bench codex_deny -- --noplot 2>&1 | tee -a bench-output.txt
# Extract timing summary for CI
echo "## Benchmark Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Performance budgets are defined in \`src/perf.rs\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -E '(time:|change:|Performance)' bench-output.txt | head -50 >> $GITHUB_STEP_SUMMARY || true
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Check for regressions
run: |
# Check for significant regressions (>20% slower)
if grep -q "Performance has regressed" bench-output.txt; then
echo "::warning::Performance regression detected"
# Extract regression details
grep -A5 "Performance has regressed" bench-output.txt || true
fi
# Check for large regressions that should fail CI (>50% slower)
if grep -E "change:.*\+[5-9][0-9]\." bench-output.txt; then
echo "::error::Significant performance regression (>50%) detected"
echo "See benchmark output for details"
exit 1
fi
# Also fail if any benchmark shows >100% regression
if grep -E "change:.*\+[0-9]{3,}\." bench-output.txt; then
echo "::error::Critical performance regression (>100%) detected"
exit 1
fi
- name: Upload benchmark results
uses: actions/upload-artifact@v7
with:
name: benchmark-results
path: |
bench-output.txt
target/criterion
retention-days: 30
- name: Save baseline (main branch only)
if: github.ref == 'refs/heads/main'
run: |
echo "Saving benchmark baseline for future comparisons"
# The criterion cache will be saved automatically
# Optional: Compare PR benchmarks against main
compare:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
needs: benchmark
steps:
- uses: actions/checkout@v7
- name: Download PR benchmark results
uses: actions/download-artifact@v8
with:
name: benchmark-results
path: pr-results
- name: Generate comparison summary
run: |
echo "## PR Benchmark Comparison" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Benchmarks ran successfully. Check the benchmark job for detailed results." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Key performance budgets:" >> $GITHUB_STEP_SUMMARY
echo "- Quick reject: < 50μs (panic threshold)" >> $GITHUB_STEP_SUMMARY
echo "- Fast path: < 500μs (panic threshold)" >> $GITHUB_STEP_SUMMARY
echo "- Full heredoc pipeline: < 20ms (panic threshold)" >> $GITHUB_STEP_SUMMARY
echo "- Hook fail-open deadline: 200ms" >> $GITHUB_STEP_SUMMARY
name: ci
on:
pull_request:
push:
branches: [main]
schedule:
# Run deep suite daily at 3am UTC
- cron: '0 3 * * *'
workflow_dispatch:
# Allow manual triggering for deep suite
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Validate Dependabot config
run: |
ruby -e 'require "yaml"; config = YAML.load_file(".github/dependabot.yml"); puts "Dependabot config:"; puts config.inspect'
- uses: dtolnay/rust-toolchain@nightly
with:
components: rustfmt, clippy
- name: Install cargo-nextest
uses: taiki-e/install-action@nextest
- name: Cache cargo registry and target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Check formatting
run: cargo fmt -- --check
- name: Run clippy
run: cargo clippy --all-targets -- -D warnings
- name: Install UBS
run: |
curl -fsSL "https://raw.githubusercontent.com/Dicklesworthstone/ultimate_bug_scanner/main/install.sh" \
| bash -s -- --easy-mode
# Add UBS to PATH for this job
echo "$HOME/.ubs" >> $GITHUB_PATH
- name: Run UBS on changed files
if: always()
run: |
echo "=== UBS Static Analysis ===" | tee -a ubs-output.log
echo "Timestamp: $(date -Iseconds)" | tee -a ubs-output.log
echo "" | tee -a ubs-output.log
echo "=== UBS Smoke Test ===" | tee -a ubs-output.log
cat > /tmp/ubs_smoke.rs <<'EOF'
fn main() {
println!("ubs smoke test");
}
EOF
if ubs --verbose /tmp/ubs_smoke.rs 2>&1 | tee -a ubs-output.log; then
echo "UBS Smoke Test: PASSED" | tee -a ubs-output.log
else
echo "UBS Smoke Test: WARNINGS (non-blocking)" | tee -a ubs-output.log
echo "::warning::UBS smoke test reported issues"
fi
echo "" | tee -a ubs-output.log
# Get changed Rust files
if [ "${{ github.event_name }}" = "pull_request" ]; then
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- "*.rs" | tr "\n" " ")
echo "Mode: Pull Request (base: ${{ github.base_ref }})" | tee -a ubs-output.log
else
CHANGED=$(git diff --name-only HEAD~1 -- "*.rs" | tr "\n" " ")
echo "Mode: Push (comparing to HEAD~1)" | tee -a ubs-output.log
fi
echo "Changed files: ${CHANGED:-none}" | tee -a ubs-output.log
echo "" | tee -a ubs-output.log
if [ -n "$CHANGED" ]; then
echo "Running UBS analysis..." | tee -a ubs-output.log
if ubs --verbose $CHANGED 2>&1 | tee -a ubs-output.log; then
echo "" | tee -a ubs-output.log
echo "UBS Result: PASSED (no issues found)" | tee -a ubs-output.log
else
echo "" | tee -a ubs-output.log
echo "UBS Result: WARNINGS (issues found - non-blocking)" | tee -a ubs-output.log
echo "::warning::UBS found potential issues in changed files"
fi
else
echo "UBS Result: SKIPPED (no Rust files changed)" | tee -a ubs-output.log
fi
echo "" | tee -a ubs-output.log
echo "=== End UBS Analysis ===" | tee -a ubs-output.log
- name: UBS summary
if: always()
run: |
echo "## UBS Static Analysis" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
cat ubs-output.log >> $GITHUB_STEP_SUMMARY 2>/dev/null || echo "No UBS output" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
- name: Upload UBS output
if: failure()
uses: actions/upload-artifact@v7
with:
name: ubs-output
path: ubs-output.log
retention-days: 7
- name: Check compilation
run: cargo check --all-targets
- name: Check lean build without rich output
run: cargo check --all-targets --no-default-features
- name: Build release binary for tests
run: cargo build --release
- name: Run tests (with JUnit XML report)
run: |
cargo nextest run --profile ci --no-fail-fast
- name: Generate test summary
if: always()
run: |
echo "## Test Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Count pack tests
PACK_TESTS=$(cargo test packs:: -- --list 2>/dev/null | grep -c "test$" || echo "0")
TOTAL_TESTS=$(cargo test -- --list 2>/dev/null | grep -c "test$" || echo "0")
echo "| Category | Count |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Total Tests | $TOTAL_TESTS |" >> $GITHUB_STEP_SUMMARY
echo "| Pack Tests | $PACK_TESTS |" >> $GITHUB_STEP_SUMMARY
- name: Test count sentinel
run: |
TOTAL_TESTS=$(cargo test -- --list 2>/dev/null | grep -c "test$" || echo "0")
MIN_TESTS=3700
if [ "$TOTAL_TESTS" -lt "$MIN_TESTS" ]; then
echo "::error::Test count dropped to $TOTAL_TESTS (minimum $MIN_TESTS). If tests were intentionally removed, update MIN_TESTS in ci.yml."
exit 1
fi
echo "Test count sentinel: $TOTAL_TESTS >= $MIN_TESTS"
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-check
path: target/nextest/ci/junit.xml
retention-days: 14
if-no-files-found: ignore
fuzz-smoke:
runs-on: ubuntu-latest
needs: check
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
with:
# cargo-fuzz defaults to building under x86_64-unknown-linux-musl
# because libFuzzer + AddressSanitizer require statically-linked
# libc; without this target installed, the build fails with
# "sanitizer is incompatible with statically linked libc" and
# "can't find crate for `core`".
targets: x86_64-unknown-linux-musl
- name: Install cargo-fuzz
uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
- name: Cache cargo registry and fuzz target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
fuzz/target
key: ${{ runner.os }}-fuzz-smoke-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml', 'fuzz/**') }}
restore-keys: |
${{ runner.os }}-fuzz-smoke-
- name: Run heredoc fuzz smoke
run: |
cd fuzz
# Build/run the fuzzer for the gnu host target. AddressSanitizer is
# incompatible with musl's statically-linked libc (crt-static) and
# fails with "sanitizer is incompatible with statically linked libc";
# the default x86_64-unknown-linux-gnu (dynamic glibc) is the
# supported ASan target.
cargo fuzz run heredoc_fuzz --target x86_64-unknown-linux-gnu -- -max_total_time=30
coverage:
runs-on: ubuntu-latest
needs: check
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
with:
components: llvm-tools-preview
- name: Install cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Cache cargo registry and target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-cov-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-cov-
- name: Run tests with coverage
run: |
cargo llvm-cov --all-features --workspace \
--ignore-filename-regex='(tests/|benches/|\.cargo/)' \
--no-report
- name: Generate coverage reports
run: |
# Generate LCOV from collected data (no re-running tests)
# Note: report subcommand doesn't accept --all-features/--workspace
cargo llvm-cov report \
--ignore-filename-regex='(tests/|benches/|\.cargo/)' \
--lcov --output-path lcov.info
# Generate JSON for programmatic threshold checking
cargo llvm-cov report \
--ignore-filename-regex='(tests/|benches/|\.cargo/)' \
--json --output-path coverage.json
# Create human-readable summary from JSON
jq -r '
"Coverage Summary:",
" Overall: \(.data[0].totals.lines.percent | . * 100 | round / 100)% lines covered",
" Functions: \(.data[0].totals.functions.percent | . * 100 | round / 100)% covered",
"",
"File coverage (lines):",
(.data[0].files | sort_by(-.summary.lines.percent) | .[:10][] |
" \(.filename | split("/") | .[-1]): \(.summary.lines.percent | . * 100 | round / 100)%")
' coverage.json > coverage-summary.txt
echo "## Coverage Summary" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat coverage-summary.txt >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
with:
files: lcov.info
fail_ci_if_error: false
verbose: true
name: dcg-coverage
flags: unittests
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
- name: Codecov upload status
if: always()
run: |
echo "## Codecov Upload" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -f lcov.info ]; then
lines=$(wc -l < lcov.info)
echo "- Coverage file: lcov.info ($lines lines)" >> $GITHUB_STEP_SUMMARY
else
echo "- Warning: Coverage file not found" >> $GITHUB_STEP_SUMMARY
fi
echo "- Upload: Attempted (check Codecov dashboard for status)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Dashboard: https://codecov.io/gh/Dicklesworthstone/destructive_command_guard" >> $GITHUB_STEP_SUMMARY
- name: Upload coverage artifact
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: |
lcov.info
coverage.json
coverage-summary.txt
retention-days: 30
- name: Check coverage thresholds (enforced)
run: |
set -euo pipefail
# Keep in sync with AGENTS.md "Coverage Job"; the
# coverage_threshold_docs test checks these values.
OVERALL_MIN="70.0"
EVALUATOR_MIN="65.0"
HOOK_MIN="70.0"
# Parse coverage from JSON using jq (more reliable than text parsing)
overall=$(jq -r '.data[0].totals.lines.percent' coverage.json)
evaluator=$(jq -r '.data[0].files[] | select(.filename | endswith("evaluator.rs")) | .summary.lines.percent' coverage.json)
hook=$(jq -r '.data[0].files[] | select(.filename | endswith("hook.rs")) | .summary.lines.percent' coverage.json)
echo "Coverage thresholds:"
echo " overall >= ${OVERALL_MIN}%"
echo " src/evaluator.rs >= ${EVALUATOR_MIN}%"
echo " src/hook.rs >= ${HOOK_MIN}%"
echo ""
echo "Observed coverage:"
printf " overall=%.2f%%\n" "$overall"
printf " src/evaluator.rs=%.2f%%\n" "$evaluator"
printf " src/hook.rs=%.2f%%\n" "$hook"
echo "coverage_overall=${overall}" >> "$GITHUB_OUTPUT"
echo "coverage_evaluator=${evaluator}" >> "$GITHUB_OUTPUT"
echo "coverage_hook=${hook}" >> "$GITHUB_OUTPUT"
failures=0
if [ -z "$overall" ] || [ "$overall" = "null" ]; then
echo "::error::Failed to parse overall coverage from coverage.json"
failures=$((failures + 1))
elif awk -v v="$overall" -v min="$OVERALL_MIN" 'BEGIN{exit !(v+0 < min+0)}'; then
printf "::error::Overall coverage %.2f%% is below ${OVERALL_MIN}%%\n" "$overall"
failures=$((failures + 1))
fi
if [ -z "$evaluator" ] || [ "$evaluator" = "null" ]; then
echo "::error::Failed to parse src/evaluator.rs coverage from coverage.json"
failures=$((failures + 1))
elif awk -v v="$evaluator" -v min="$EVALUATOR_MIN" 'BEGIN{exit !(v+0 < min+0)}'; then
printf "::error::src/evaluator.rs coverage %.2f%% is below ${EVALUATOR_MIN}%%\n" "$evaluator"
failures=$((failures + 1))
fi
if [ -z "$hook" ] || [ "$hook" = "null" ]; then
echo "::error::Failed to parse src/hook.rs coverage from coverage.json"
failures=$((failures + 1))
elif awk -v v="$hook" -v min="$HOOK_MIN" 'BEGIN{exit !(v+0 < min+0)}'; then
printf "::error::src/hook.rs coverage %.2f%% is below ${HOOK_MIN}%%\n" "$hook"
failures=$((failures + 1))
fi
if [ "$failures" -gt 0 ]; then
echo "::error::Coverage thresholds not met (${failures} failure(s))"
exit 1
fi
echo "Coverage thresholds satisfied."
# Memory leak detection tests
memory-tests:
runs-on: ubuntu-latest
needs: check
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
- name: Cache cargo registry and target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-memory-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-memory-
- name: Run memory tests
id: memory_tests
run: |
echo "=== DCG Memory Leak Tests ===" | tee memory-output.log
echo "Timestamp: $(date -Iseconds)" | tee -a memory-output.log
echo "Runner: ${{ runner.os }}" | tee -a memory-output.log
echo "Rust: $(rustc --version)" | tee -a memory-output.log
echo "" | tee -a memory-output.log
# Get baseline system memory
echo "=== System Memory Baseline ===" | tee -a memory-output.log
free -h | tee -a memory-output.log
echo "" | tee -a memory-output.log
echo "=== Running Memory Tests ===" | tee -a memory-output.log
# Memory tests must run sequentially for accurate measurements
# Release mode for realistic performance characteristics
if cargo test --test memory_tests --release -- --nocapture --test-threads=1 2>&1 | tee -a memory-output.log; then
echo "" | tee -a memory-output.log
echo "=== Memory Tests: ALL PASSED ===" | tee -a memory-output.log
echo "memory_tests_result=passed" >> $GITHUB_OUTPUT
else
echo "" | tee -a memory-output.log
echo "=== Memory Tests: FAILED ===" | tee -a memory-output.log
echo "memory_tests_result=failed" >> $GITHUB_OUTPUT
exit 1
fi
- name: Parse memory metrics
if: always()
run: |
echo "=== Memory Test Metrics ===" | tee -a memory-metrics.log
echo "" | tee -a memory-metrics.log
# Extract metrics from test output
echo "Test Results:" | tee -a memory-metrics.log
grep -E "^memory_" memory-output.log | tee -a memory-metrics.log || echo "No metrics found" | tee -a memory-metrics.log
echo "" | tee -a memory-metrics.log
echo "Growth Summary:" | tee -a memory-metrics.log
grep -E "final.*growth" memory-output.log | tee -a memory-metrics.log || echo "No growth data" | tee -a memory-metrics.log
echo "" | tee -a memory-metrics.log
echo "Pass/Fail:" | tee -a memory-metrics.log
grep -E "(PASSED|FAILED|panicked)" memory-output.log | tee -a memory-metrics.log || echo "No status found" | tee -a memory-metrics.log
- name: Memory test summary
if: always()
run: |
echo "## Memory Leak Tests" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Result badge
if grep -q "ALL PASSED" memory-output.log; then
echo "**Result:** ✅ All tests passed" >> $GITHUB_STEP_SUMMARY
else
echo "**Result:** ❌ Tests failed" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
# Metrics table
echo "### Memory Growth by Test" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Test | Final Growth | Limit | Status |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------------|-------|--------|" >> $GITHUB_STEP_SUMMARY
# Parse and format metrics
grep -E "final.*growth" memory-output.log | while read line; do
test_name=$(echo "$line" | grep -oP "^[^:]+")
growth=$(echo "$line" | grep -oP "growth: \\K[0-9]+ KB" || echo "?")
limit=$(echo "$line" | grep -oP "limit: \\K[0-9]+ KB" || echo "?")
if echo "$line" | grep -q "PASSED"; then
status="✅"
else
status="❌"
fi
echo "| $test_name | $growth | $limit | $status |" >> $GITHUB_STEP_SUMMARY
done
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Full Output" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
tail -50 memory-output.log >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Upload memory test artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: memory-test-output
path: |
memory-output.log
memory-metrics.log
retention-days: 14
# Performance benchmark enforcement (push to main only)
# Runs benchmarks and checks against performance budgets defined in src/perf.rs
benchmarks:
runs-on: ubuntu-latest
needs: check
# Only run on push to main, not on PRs (benchmarks are noisy)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
- name: Cache cargo registry and target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-bench-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-bench-
- name: Run benchmarks
run: |
# Run benchmarks and capture output
cargo bench --bench heredoc_perf -- --noplot 2>&1 | tee benchmark_output.txt
cargo bench --bench codex_deny -- --noplot 2>&1 | tee -a benchmark_output.txt
echo "## Benchmark Results" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
grep -E "^(tier|pack|core|shell|language|full|hook)" benchmark_output.txt | head -50 >> $GITHUB_STEP_SUMMARY || true
echo '```' >> $GITHUB_STEP_SUMMARY
- name: Check performance budgets
run: |
# Extract timing summaries and check against budgets
# Budgets from src/perf.rs:
# - Quick reject: 50μs panic
# - Fast path: 500μs panic
# - Pattern match: 1ms panic
# - Heredoc extract: 2ms panic
# - Full heredoc pipeline: 20ms panic
# - Hook fail-open deadline: 200ms
echo "## Performance Budget Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
PANIC_VIOLATIONS=0
# Check for any results exceeding 1s (major benchmark sanity cap)
if grep -E "time:.*\[.*[0-9]+\.[0-9]+ s" benchmark_output.txt; then
echo "::error::Some benchmarks exceeded 1 second - major regression detected"
PANIC_VIOLATIONS=$((PANIC_VIOLATIONS + 1))
fi
# Check full_pipeline benchmarks (budget: 20ms panic)
if grep -A1 "full_pipeline" benchmark_output.txt | grep -E "time:.*\[.*([2-9][0-9]\.[0-9]+ ms|[0-9]{3,}\.[0-9]+ ms)"; then
echo "::warning::Full heredoc pipeline benchmark exceeds 20ms budget"
PANIC_VIOLATIONS=$((PANIC_VIOLATIONS + 1))
fi
if [ $PANIC_VIOLATIONS -gt 0 ]; then
echo "::error::$PANIC_VIOLATIONS performance budget violations detected"
echo "Budget violations: $PANIC_VIOLATIONS" >> $GITHUB_STEP_SUMMARY
# For now, warn but don't fail (benchmarks can be noisy in CI)
# exit 1
else
echo "All benchmarks within budget" >> $GITHUB_STEP_SUMMARY
fi
- name: Upload benchmark results
uses: actions/upload-artifact@v7
with:
name: benchmark-results
path: benchmark_output.txt
retention-days: 30
# End-to-end shell script tests
e2e:
runs-on: ubuntu-latest
needs: check
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
- name: Cache cargo registry and target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Build release binary
run: cargo build --release
- name: Run E2E tests
id: e2e
run: |
set +e
set -o pipefail
mkdir -p e2e-artifacts
./scripts/e2e_test.sh --verbose --binary target/release/dcg --json --artifacts e2e-artifacts | tee e2e_output.json >/dev/null
EXIT_CODE=${PIPESTATUS[0]}
echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
echo "## E2E Test Results" >> $GITHUB_STEP_SUMMARY
# Extract summary from JSON output
if jq -e '.summary' e2e_output.json >/dev/null 2>&1; then
echo '```json' >> $GITHUB_STEP_SUMMARY
jq '.summary' e2e_output.json >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
FAILED=$(jq -r '.summary.failed // 0' e2e_output.json 2>/dev/null || echo "0")
if [ "$FAILED" != "0" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "### First Failure" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
jq -r '.tests[] | select(.result == "fail") | "\(.id): \(.name)\n\n\(.output // "")"' e2e_output.json | head -n 40 >> $GITHUB_STEP_SUMMARY || true
echo '```' >> $GITHUB_STEP_SUMMARY
fi
else
echo '```' >> $GITHUB_STEP_SUMMARY
tail -20 e2e_output.json >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
fi
exit 0
- name: Upload E2E artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: e2e-artifacts
path: |
e2e_output.json
e2e-artifacts/
retention-days: 14
if-no-files-found: ignore
- name: Check E2E result
if: steps.e2e.outputs.exit_code != '0'
run: |
echo "::error::E2E suite failed (exit code ${{ steps.e2e.outputs.exit_code }}). See the 'e2e-artifacts' artifact and the step summary for the first failure."
exit 1
# Bats install/uninstall/agent-config tests
bats:
runs-on: ubuntu-latest
needs: check
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
- name: Cache cargo registry and target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Install bats-core
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq bats
- name: Build release binary
run: cargo build --release
- name: Run bats tests
id: bats
run: |
set +e
bats --tap tests/install/*.bats 2>&1 | tee bats-output.log
EXIT_CODE=${PIPESTATUS[0]}
echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
echo "## Bats Install Tests" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
tail -30 bats-output.log >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
exit 0
- name: Upload bats output
if: always()
uses: actions/upload-artifact@v7
with:
name: bats-output
path: bats-output.log
retention-days: 14
if-no-files-found: ignore
- name: Check bats result
if: steps.bats.outputs.exit_code != '0'
run: |
echo "::error::Bats tests failed (exit code ${{ steps.bats.outputs.exit_code }}). See 'bats-output' artifact."
exit 1
# Real Codex CLI smoke tests. This runs only on pushes to main because it
# requires networked Codex API calls and consumes quota.
codex-e2e:
runs-on: ubuntu-latest
needs: check
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
- name: Cache cargo registry and target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-codex-e2e-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Build release binary
run: cargo build --release --bin dcg
- name: Install dcg on PATH
run: |
mkdir -p "$HOME/.local/bin"
cp target/release/dcg "$HOME/.local/bin/dcg"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Check Codex API key secret
id: codex_secret
env:
OPENAI_API_KEY: ${{ secrets.CODEX_API_KEY }}
run: |
if [ -z "${OPENAI_API_KEY}" ]; then
echo "available=false" >> "$GITHUB_OUTPUT"
echo "::notice::Skipping Codex CLI install because CODEX_API_KEY is not configured."
echo "## Codex E2E" >> "$GITHUB_STEP_SUMMARY"
echo "CODEX_API_KEY is not configured; the harness will self-skip." >> "$GITHUB_STEP_SUMMARY"
else
echo "available=true" >> "$GITHUB_OUTPUT"
fi
- name: Install Codex CLI
id: codex_install
if: steps.codex_secret.outputs.available == 'true'
run: |
set +e
npm i -g @openai/codex@latest
status=$?
set -e
if [ "$status" -eq 0 ]; then
echo "installed=true" >> "$GITHUB_OUTPUT"
codex --version
else
echo "installed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Codex CLI install failed; the harness will self-skip if codex is unavailable."
fi
- name: Authenticate Codex CLI
if: steps.codex_secret.outputs.available == 'true' && steps.codex_install.outputs.installed == 'true'
env:
OPENAI_API_KEY: ${{ secrets.CODEX_API_KEY }}
run: |
set +e
printenv OPENAI_API_KEY | codex login --with-api-key
auth_status=$?
set -e
if [ "$auth_status" -ne 0 ]; then
echo "::notice::Codex CLI authentication failed; the harness will self-skip if login status is unavailable."
fi
codex login status || true
- name: Run Codex E2E harness
id: codex_e2e
run: |
set +e
set -o pipefail
mkdir -p /tmp/codex_e2e_artifacts
./scripts/e2e_codex.sh --verbose --json --artifacts /tmp/codex_e2e_artifacts --dcg-binary "$HOME/.local/bin/dcg" \
2>&1 | tee codex_e2e_output.jsonl
EXIT_CODE=${PIPESTATUS[0]}
SKIP_REASON=""
if [ "$EXIT_CODE" -ne 0 ]; then
if grep -Eiq "quota|rate limit|429" codex_e2e_output.jsonl /tmp/codex_e2e_artifacts/trace.jsonl 2>/dev/null; then
SKIP_REASON="codex API quota exhausted"
elif grep -Eiq "not authenticated|authentication|unauthorized|expired" codex_e2e_output.jsonl /tmp/codex_e2e_artifacts/trace.jsonl 2>/dev/null; then
SKIP_REASON="codex authentication unavailable or expired"
elif grep -Eiq "network|timed out|timeout" codex_e2e_output.jsonl /tmp/codex_e2e_artifacts/trace.jsonl 2>/dev/null; then
SKIP_REASON="codex network unavailable"
fi
if [ -n "$SKIP_REASON" ]; then
echo "::notice::Codex E2E treated as transient skip: $SKIP_REASON"
EXIT_CODE=0
fi
fi
echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
echo "skip_reason=$SKIP_REASON" >> "$GITHUB_OUTPUT"
echo "## Codex E2E" >> "$GITHUB_STEP_SUMMARY"
if [ -n "$SKIP_REASON" ]; then
echo "Transient skip: $SKIP_REASON" >> "$GITHUB_STEP_SUMMARY"
fi
summary_line="$(grep '"type":"summary"' codex_e2e_output.jsonl | tail -n 1 || true)"
if [ -n "$summary_line" ]; then
echo '```json' >> "$GITHUB_STEP_SUMMARY"
echo "$summary_line" | jq . >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || echo "$summary_line" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
else
echo '```' >> "$GITHUB_STEP_SUMMARY"
tail -40 codex_e2e_output.jsonl >> "$GITHUB_STEP_SUMMARY" || true
echo '```' >> "$GITHUB_STEP_SUMMARY"
fi
exit 0
- name: Upload Codex E2E artifacts
if: steps.codex_e2e.outputs.exit_code != '0'
uses: actions/upload-artifact@v7
with:
name: codex-e2e-artifacts
path: |
codex_e2e_output.jsonl
/tmp/codex_e2e_artifacts/
retention-days: 14
if-no-files-found: ignore
- name: Check Codex E2E result
if: steps.codex_e2e.outputs.exit_code != '0'
run: |
echo "::error::Codex E2E failed (exit code ${{ steps.codex_e2e.outputs.exit_code }}). See the 'codex-e2e-artifacts' artifact and the step summary for details."
exit 1
# Scan-mode regression fixtures (ensures scan output stays stable)
scan-regression:
runs-on: ubuntu-latest
needs: check
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
- name: Cache cargo registry and target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-scan-regression-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Build release binary
run: cargo build --release
- name: Run scan regression fixtures
id: scan_regression
run: |
set +e
set -o pipefail
./scripts/scan_regression.sh 2>&1 | tee scan_regression.log
EXIT_CODE=${PIPESTATUS[0]}
echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
if [ -f /tmp/dcg_scan_regression_actual.json ]; then
cp /tmp/dcg_scan_regression_actual.json scan_regression_actual.json
fi
echo "## Scan Regression (fixtures)" >> $GITHUB_STEP_SUMMARY
if [ -f scan_regression_actual.json ] && jq -e '.summary' scan_regression_actual.json >/dev/null 2>&1; then
echo '```json' >> $GITHUB_STEP_SUMMARY
jq '.summary' scan_regression_actual.json >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
else
echo '```' >> $GITHUB_STEP_SUMMARY
tail -40 scan_regression.log >> $GITHUB_STEP_SUMMARY || true
echo '```' >> $GITHUB_STEP_SUMMARY
fi
if [ "$EXIT_CODE" != "0" ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Failure Details" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
tail -80 scan_regression.log >> $GITHUB_STEP_SUMMARY || true
echo '```' >> $GITHUB_STEP_SUMMARY
fi
exit 0
- name: Upload scan regression artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: scan-regression-artifacts
path: |
scan_regression.log
scan_regression_actual.json
retention-days: 14
if-no-files-found: ignore
- name: Check scan regression result
if: steps.scan_regression.outputs.exit_code != '0'
run: |
echo "::error::Scan regression mismatch (exit code ${{ steps.scan_regression.outputs.exit_code }}). See the 'scan-regression-artifacts' artifact and the step summary for details."
exit 1
# Process-per-invocation perf regression gate (compares against committed baseline JSON)
perf-regression:
runs-on: ubuntu-latest
needs: check
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
- name: Cache cargo registry and target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-perf-regression-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Build release binary
run: cargo build --release
- name: Run perf baseline + compare to repo baseline
id: perf
run: |
set +e
BASELINE_JSON="perf/baselines/2026-01-11-after-lazy.json"
CURRENT_JSON="perf-current.json"
REPORT_MD="perf-regression-report.md"
python3 scripts/perf_baseline.py \
--bin target/release/dcg \
--output "$CURRENT_JSON" \
--warmup 10 \
--runs 80 \
--skip-trace
GEN_EXIT=$?
if [ "$GEN_EXIT" -ne 0 ]; then
echo "exit_code=$GEN_EXIT" >> $GITHUB_OUTPUT
echo "::error::perf baseline generation failed (exit code $GEN_EXIT)"
exit 0
fi
python3 - "$BASELINE_JSON" "$CURRENT_JSON" "$REPORT_MD" <<'PY'
import json
import sys
baseline_path = sys.argv[1]
current_path = sys.argv[2]
report_path = sys.argv[3]
mult = 2.5
slack_ms = 5.0
rss_slack_kb = 8192
with open(baseline_path, "r", encoding="utf-8") as handle:
baseline = json.load(handle)
with open(current_path, "r", encoding="utf-8") as handle:
current = json.load(handle)
baseline_cases = {c["id"]: c for c in baseline.get("cases", [])}
current_cases = {c["id"]: c for c in current.get("cases", [])}
violations = []
rows = []
for case_id in sorted(baseline_cases.keys()):
if case_id not in current_cases:
violations.append(f"missing case in current run: {case_id}")
continue
b = baseline_cases[case_id].get("metrics", {})
c = current_cases[case_id].get("metrics", {})
b_p50 = float(b.get("p50_ms", 0.0))
c_p50 = float(c.get("p50_ms", 0.0))
limit_p50 = b_p50 * mult + slack_ms
status = "OK" if c_p50 <= limit_p50 else "REGRESSED"
rows.append((case_id, b_p50, c_p50, limit_p50, status))
if c_p50 > limit_p50:
violations.append(
f"{case_id} p50_ms {c_p50:.2f} > {limit_p50:.2f} (baseline {b_p50:.2f}, mult {mult}x + {slack_ms}ms)"
)
b_rss = b.get("max_rss_kb")
c_rss = c.get("max_rss_kb")
if isinstance(b_rss, int) and isinstance(c_rss, int):
rss_limit = max(int(b_rss * 2.0), b_rss + rss_slack_kb)
if c_rss > rss_limit:
violations.append(
f"{case_id} max_rss_kb {c_rss} > {rss_limit} (baseline {b_rss})"
)
# Also fail if new unexpected cases are added (keeps the harness stable)
extra_cases = sorted(set(current_cases.keys()) - set(baseline_cases.keys()))
if extra_cases:
violations.append(f"unexpected new cases in current run: {', '.join(extra_cases)}")
# Write report (Markdown for GHA summary + artifacts)
lines = []
lines.append("## Perf Regression Gate")
lines.append("")
lines.append(f"- Baseline: `{baseline_path}`")
lines.append(f"- Threshold: `current_p50_ms <= baseline_p50_ms * {mult} + {slack_ms}ms`")
lines.append(f"- RSS slack: `max(baseline*2, baseline+{rss_slack_kb}KB)` (when available)")
lines.append("")
lines.append("| Case | Baseline p50 (ms) | Current p50 (ms) | Limit (ms) | Status |")
lines.append("|------|-------------------|-----------------|-----------|--------|")
for case_id, b_p50, c_p50, limit_p50, status in rows:
lines.append(f"| `{case_id}` | {b_p50:.2f} | {c_p50:.2f} | {limit_p50:.2f} | {status} |")
lines.append("")
if violations:
lines.append("### Violations")
lines.append("")
for v in violations[:10]:
lines.append(f"- {v}")
if len(violations) > 10:
lines.append(f"- ... and {len(violations) - 10} more")
lines.append("")
with open(report_path, "w", encoding="utf-8") as handle:
handle.write("\n".join(lines))
handle.write("\n")
if violations:
for v in violations[:5]:
print(f"::error::{v}")
sys.exit(1)
sys.exit(0)
PY
CHECK_EXIT=$?
echo "exit_code=$CHECK_EXIT" >> $GITHUB_OUTPUT
echo "## Perf Regression" >> $GITHUB_STEP_SUMMARY
cat "$REPORT_MD" >> $GITHUB_STEP_SUMMARY 2>/dev/null || true
exit 0
- name: Upload perf regression artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: perf-regression-artifacts
path: |
perf-current.json
perf-regression-report.md
retention-days: 14
if-no-files-found: ignore
- name: Check perf regression result
if: steps.perf.outputs.exit_code != '0'
run: |
echo "::error::Perf regression gate failed (exit code ${{ steps.perf.outputs.exit_code }}). See 'perf-regression-artifacts' and the step summary."
exit 1
# Deep suite: fuzzing (scheduled or manual only)
fuzz:
runs-on: ubuntu-latest
# Only run on schedule or manual trigger, not on every PR
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
with:
components: llvm-tools-preview
# See fuzz-smoke job above — cargo-fuzz needs the musl target to
# link libFuzzer + AddressSanitizer with a statically-linked libc.
targets: x86_64-unknown-linux-musl
- name: Install cargo-fuzz
uses: taiki-e/install-action@v2
with:
tool: cargo-fuzz
- name: Cache cargo registry and fuzz corpus
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
fuzz/corpus
fuzz/artifacts
key: ${{ runner.os }}-fuzz-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml', 'fuzz/**') }}
restore-keys: |
${{ runner.os }}-fuzz-
- name: Run fuzz tests (time-limited)
run: |
cd fuzz
echo "## Fuzzing Results" >> $GITHUB_STEP_SUMMARY
for target in fuzz_context fuzz_evaluate fuzz_hook_input fuzz_normalize fuzz_heredoc_trigger fuzz_heredoc_extract fuzz_heredoc_language fuzz_shell_extract heredoc_fuzz ast_matcher_fuzz; do
echo "Fuzzing $target (~60s runtime + build)..."
timeout 10m cargo fuzz run "$target" -- -max_total_time=60 || true
echo "- $target: completed" >> $GITHUB_STEP_SUMMARY
done
- name: Upload fuzz artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: fuzz-artifacts
path: fuzz/artifacts/
retention-days: 30
if-no-files-found: ignore
name: cli-version-audit
on:
schedule:
# Weekly audit (Sunday 02:30 UTC)
- cron: "30 2 * * 0"
workflow_dispatch:
jobs:
version_audit:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- name: Run CLI version checker
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./scripts/check_cli_versions.sh
# dcg-scan.yml — Scan PR diffs for destructive commands
#
# This workflow runs `dcg scan --git-diff` on pull requests to detect
# dangerous patterns in changed files before they're merged.
#
# USAGE: Copy this file to your project's .github/workflows/ directory.
#
# CONFIGURATION:
# - Set DCG_FAIL_ON to control exit behavior:
# - "error" (default): Fail only on error-severity findings
# - "warning": Fail on warnings and errors
# - "none": Never fail (informational only)
#
# - Set DCG_VERSION to pin a specific version:
# - "latest" (default): Use latest release
# - "v0.2.0": Use specific version tag
name: dcg-scan
on:
pull_request:
types: [opened, synchronize, reopened]
env:
CARGO_TERM_COLOR: always
# Fail policy: "error" | "warning" | "none"
DCG_FAIL_ON: error
# Version to use: "latest" or specific tag like "v0.2.0"
DCG_VERSION: latest
jobs:
scan:
name: Scan for destructive commands
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
# Fetch enough history for git diff
fetch-depth: 0
- name: Download dcg binary
run: |
if [ "$DCG_VERSION" = "latest" ]; then
RELEASE_URL="https://api.github.com/repos/Dicklesworthstone/destructive_command_guard/releases/latest"
else
RELEASE_URL="https://api.github.com/repos/Dicklesworthstone/destructive_command_guard/releases/tags/$DCG_VERSION"
fi
# Get download URL for Linux x86_64 binary
DOWNLOAD_URL=$(curl -sL "$RELEASE_URL" | \
jq -r '.assets[] | select(.name | contains("linux") and contains("x86_64")) | .browser_download_url' | \
head -1)
if [ -z "$DOWNLOAD_URL" ] || [ "$DOWNLOAD_URL" = "null" ]; then
echo "::warning::No pre-built binary found, building from source..."
# Fallback: build from source
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
source "$HOME/.cargo/env"
cargo install destructive_command_guard --locked
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
else
echo "Downloading dcg from: $DOWNLOAD_URL"
curl -sL "$DOWNLOAD_URL" -o dcg
chmod +x dcg
sudo mv dcg /usr/local/bin/
fi
- name: Verify dcg installation
run: dcg --version
- name: Determine base commit
id: base
run: |
# Use the merge base between PR branch and target branch
BASE_SHA=$(git merge-base "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}")
echo "sha=$BASE_SHA" >> $GITHUB_OUTPUT
echo "Base commit: $BASE_SHA"
- name: Run dcg scan on changed files
id: scan
run: |
set +e # Don't exit on error so we can capture the exit code
# Build the diff range
DIFF_RANGE="${{ steps.base.outputs.sha }}...${{ github.event.pull_request.head.sha }}"
echo "Scanning diff range: $DIFF_RANGE"
# Run the scan (stdout=JSON, stderr=debug messages)
dcg scan \
--git-diff "$DIFF_RANGE" \
--format json \
--fail-on "$DCG_FAIL_ON" \
> scan-results.json
SCAN_EXIT=$?
echo "exit_code=$SCAN_EXIT" >> $GITHUB_OUTPUT
# Always output results for debugging
cat scan-results.json
# Parse summary for step summary
if [ -f scan-results.json ] && jq -e '.summary' scan-results.json > /dev/null 2>&1; then
TOTAL=$(jq '.summary.findings_total // 0' scan-results.json)
ERRORS=$(jq '.summary.severities.error // 0' scan-results.json)
WARNINGS=$(jq '.summary.severities.warning // 0' scan-results.json)
echo "## DCG Scan Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Total Findings | $TOTAL |" >> $GITHUB_STEP_SUMMARY
echo "| Errors | $ERRORS |" >> $GITHUB_STEP_SUMMARY
echo "| Warnings | $WARNINGS |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$SCAN_EXIT" -ne 0 ]; then
echo "**Status: FAILED** (exit code $SCAN_EXIT)" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Review the findings below and fix any destructive patterns before merging." >> $GITHUB_STEP_SUMMARY
else
echo "**Status: PASSED**" >> $GITHUB_STEP_SUMMARY
fi
else
echo "## DCG Scan Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "No findings or scan output could not be parsed." >> $GITHUB_STEP_SUMMARY
fi
exit $SCAN_EXIT
- name: Upload scan results
if: always()
uses: actions/upload-artifact@v7
with:
name: dcg-scan-results
path: scan-results.json
retention-days: 14
if-no-files-found: ignore
# Optional: Build from source (for repos that want to build dcg themselves)
# Uncomment this job and remove the "Download dcg binary" step above
#
# build-dcg:
# name: Build dcg from source
# runs-on: ubuntu-latest
# steps:
# - uses: dtolnay/rust-toolchain@nightly
# - name: Build dcg
# run: cargo install destructive_command_guard --locked
# - name: Upload binary
# uses: actions/upload-artifact@v7
# with:
# name: dcg-binary
# path: ~/.cargo/bin/dcg
name: dist
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
with:
components: rustfmt, clippy
- name: Check formatting
run: cargo fmt -- --check
- name: Run clippy
run: cargo clippy --all-targets -- -D warnings
- name: Run tests
run: cargo test --lib
build:
needs: test
strategy:
fail-fast: false
matrix:
include:
# Linux x86_64 — musl for portability. The previous gnu target
# linked against the build runner's glibc, which on
# ubuntu-latest is currently 2.39+ and rejects any host with
# glibc < 2.39 (Ubuntu 22.04 LTS, RHEL 8/9, etc.). See #114.
# `self_update`'s `rustls` feature avoids the OpenSSL link that
# would normally make musl builds painful — dcg has no other
# C-using deps, so musl-tools + the static target are
# sufficient.
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: dcg
apt_install: musl-tools
# Linux ARM64 — gnu stays. musl on ARM has its own packaging
# ecosystem we don't currently support, and the gnu build
# runs on the native ubuntu-24.04-arm runner which keeps the
# binary kernel-compatible with every aarch64 distro we ship
# to. See #112 for the v0.5.1 release where the aarch64 asset
# was actually an x86-64 binary — fixed by the native ARM
# runner already in this matrix.
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
artifact_name: dcg
# macOS Intel
- os: macos-15-intel
target: x86_64-apple-darwin
artifact_name: dcg
# macOS Apple Silicon
- os: macos-14
target: aarch64-apple-darwin
artifact_name: dcg
# Windows
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: dcg.exe
runs-on: ${{ matrix.os }}
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
with:
targets: ${{ matrix.target }}
- name: Install apt build deps (Linux)
if: matrix.apt_install != ''
run: |
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ${{ matrix.apt_install }}
- name: Build release binary
run: cargo build --release --target ${{ matrix.target }}
- name: Verify Linux x86_64 musl binary has no glibc symbols
if: matrix.target == 'x86_64-unknown-linux-musl'
run: |
set -euo pipefail
# Catch a future regression where the musl matrix entry
# silently links against glibc anyway. `objdump -T` on a
# static musl binary prints "no dynamic symbol table" to
# stderr and exits 0; the grep against `GLIBC_` is the
# actual gate. Any GLIBC_* symbol surfacing here is a
# release-blocker (see #114 for the original regression).
# We pipe stderr to stdout so the dump-failure case (missing
# binary, unreadable file) shows up in the job log instead
# of getting silently swallowed.
binary="target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
# Explicit existence check: without it, a missing binary
# would make `objdump -T <missing> | grep -q GLIBC_` return
# exit 1 (no match), the `if` would evaluate to false, and
# we'd echo "verified" against a binary that doesn't exist.
# That false negative is a release-engineering footgun
# (cargo could legitimately skip the build under caching
# weirdness), so guard the gate against it directly.
if [ ! -f "$binary" ]; then
echo "::error::expected built binary not found at $binary"
exit 1
fi
if objdump -T "$binary" 2>&1 | grep -q "GLIBC_"; then
echo "::error::musl binary unexpectedly contains GLIBC symbols — release would re-introduce #114"
objdump -T "$binary" | grep "GLIBC_" || true
exit 1
fi
echo "musl binary verified: no GLIBC_* symbols present"
- name: Verify Linux aarch64 binary is actually aarch64
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
set -euo pipefail
# Pin against the v0.5.1 regression (#112): the published
# aarch64 tarball contained an x86-64 ELF. Native ARM
# runners in this matrix make that impossible by
# construction, but the file-type check is cheap insurance.
binary="target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
if [ ! -f "$binary" ]; then
echo "::error::expected built binary not found at $binary"
exit 1
fi
arch_str="$(file "$binary")"
if ! echo "$arch_str" | grep -q "aarch64"; then
echo "::error::aarch64 build produced a non-aarch64 binary — release would re-introduce #112"
echo "$arch_str"
exit 1
fi
echo "aarch64 binary verified: $arch_str"
- name: Create tarball (Unix)
if: runner.os != 'Windows'
run: |
mkdir -p dist
TARBALL="dcg-${{ matrix.target }}.tar.xz"
cd target/${{ matrix.target }}/release
tar -cJf "../../../dist/$TARBALL" ${{ matrix.artifact_name }}
cd ../../../dist
shasum -a 256 "$TARBALL" > "$TARBALL.sha256"
- name: Verify Windows binary is actually PE32+
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: |
# Pin against the v0.5.1 regression (#115): the published Windows
# assets were Linux ELF binaries renamed to `dcg.exe`. The GH
# Actions test job had failed and a fallback build path
# (cross-compile on a non-Windows host) silently produced ELF
# output and packaged it as Windows. windows-latest runs natively
# so cargo cannot produce ELF here by construction, but checking
# the magic bytes is cheap insurance against a future regression
# (e.g. a cached target/ leaking the wrong artifact).
$binary = "target/${{ matrix.target }}/release/${{ matrix.artifact_name }}"
if (-not (Test-Path $binary)) {
Write-Output "::error::expected built binary not found at $binary"
exit 1
}
$magic = [System.IO.File]::ReadAllBytes($binary)[0..1]
$mz = [BitConverter]::ToString($magic)
if ($mz -ne "4D-5A") {
Write-Output "::error::Windows build produced a non-PE32+ binary (magic=$mz, expected 4D-5A 'MZ') — release would re-introduce #115"
exit 1
}
Write-Output "Windows binary verified: PE32+ magic 'MZ' present"
- name: Create zip (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path dist | Out-Null
$zipName = "dcg-${{ matrix.target }}.zip"
Compress-Archive -Path "target/${{ matrix.target }}/release/${{ matrix.artifact_name }}" -DestinationPath "dist/$zipName"
cd dist
$hash = (Get-FileHash -Algorithm SHA256 $zipName).Hash.ToLower()
"$hash $zipName" | Out-File -Encoding ASCII "$zipName.sha256"
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.target }}
path: dist/*
release:
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v7
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: dist
merge-multiple: true
- name: List artifacts
run: ls -laR dist
- name: Install cosign
uses: sigstore/cosign-installer@v4.1.2
- name: Sign release artifacts (sigstore bundle)
run: |
set -euo pipefail
shopt -s nullglob
cd dist
for f in dcg-*.tar.xz dcg-*.zip; do
cosign sign-blob --yes --bundle "${f}.sigstore.json" "$f"
done
- name: Copy install scripts to dist
run: |
cp install.sh dist/ 2>/dev/null || echo "No install.sh found"
cp install.ps1 dist/ 2>/dev/null || echo "No install.ps1 found"
- name: Checksum + sign install scripts (git_safety_guard-ythp)
# Publishes install.sh.sha256 / install.ps1.sha256 (with the
# `<sha> <basename>` format `shasum -a 256 -c` understands) plus
# cosign sigstore bundles. `dcg update` downloads the script to a
# tempfile, fetches the sha256, verifies, and only then exec's it.
# Without this the installer-fetch step is a supply-chain trust
# window even when pinned to a tag.
run: |
set -euo pipefail
shopt -s nullglob
cd dist
for script in install.sh install.ps1; do
[ -f "$script" ] || continue
shasum -a 256 "$script" > "$script.sha256"
cosign sign-blob --yes --bundle "$script.sigstore.json" "$script"
done
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
name: ${{ github.ref_name }}
draft: false
prerelease: false
generate_release_notes: true
files: |
dist/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
notify-homebrew-tap:
name: Notify Homebrew Tap
runs-on: ubuntu-latest
needs: release
timeout-minutes: 5
continue-on-error: true
env:
HAS_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN != '' }}
steps:
- name: Check for token
id: check
run: |
if [[ "${{ env.HAS_TOKEN }}" != "true" ]]; then
echo "::warning::HOMEBREW_TAP_TOKEN not configured, skipping notification"
echo "skip=true" >> $GITHUB_OUTPUT
fi
- name: Extract version from tag
if: steps.check.outputs.skip != 'true'
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Trigger formula update
if: steps.check.outputs.skip != 'true'
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
repository: Dicklesworthstone/homebrew-tap
event-type: formula-update
client-payload: |
{
"tool": "dcg",
"version": "${{ steps.version.outputs.version }}"
}
notify-scoop-bucket:
name: Notify Scoop Bucket
runs-on: ubuntu-latest
needs: release
timeout-minutes: 5
continue-on-error: true
env:
HAS_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN != '' }}
steps:
- name: Check for token
id: check
run: |
if [[ "${{ env.HAS_TOKEN }}" != "true" ]]; then
echo "::warning::SCOOP_BUCKET_TOKEN not configured, skipping notification"
echo "skip=true" >> $GITHUB_OUTPUT
fi
- name: Extract version from tag
if: steps.check.outputs.skip != 'true'
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Trigger manifest update
if: steps.check.outputs.skip != 'true'
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1
with:
token: ${{ secrets.SCOOP_BUCKET_TOKEN }}
repository: Dicklesworthstone/scoop-bucket
event-type: manifest-update
client-payload: |
{
"tool": "dcg",
"version": "${{ steps.version.outputs.version }}"
}
name: history-e2e
on:
pull_request:
paths:
- "src/history/**"
- "tests/common/**"
- "tests/history_*.rs"
- "tests/e2e/run_history_e2e.sh"
- ".github/workflows/history-e2e.yml"
push:
branches: [main]
paths:
- "src/history/**"
- "tests/common/**"
- "tests/history_*.rs"
- "tests/e2e/run_history_e2e.sh"
- ".github/workflows/history-e2e.yml"
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
- name: Build release
run: cargo build --release
- name: Run history E2E
run: |
chmod +x tests/e2e/run_history_e2e.sh
tests/e2e/run_history_e2e.sh 2>&1 | tee history-e2e.log
- name: Upload logs
if: failure()
uses: actions/upload-artifact@v7
with:
name: history-e2e-logs
path: history-e2e.log
# installer-notify.yml
# Copy this to .github/workflows/ in your project
# Notifies ACFS when install.sh changes
#
# Setup:
# 1. Create a GitHub PAT with `repo` scope
# 2. Add it as ACFS_DISPATCH_TOKEN secret in your repo
# 3. Copy this file to .github/workflows/
name: Notify ACFS of Installer Change
on:
push:
branches: [main]
paths:
- 'install.sh'
- 'scripts/install.sh'
- '**/install.sh'
pull_request:
branches: [main]
paths:
- 'install.sh'
- 'scripts/install.sh'
- '**/install.sh'
concurrency:
group: installer-notify-${{ github.ref }}
cancel-in-progress: true
jobs:
notify-acfs:
# Only notify on push to main, not PRs
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Compute installer SHA256
id: checksum
run: |
# Find the installer file
if [ -f install.sh ]; then
INSTALLER_PATH="install.sh"
elif [ -f scripts/install.sh ]; then
INSTALLER_PATH="scripts/install.sh"
else
echo "No installer found"
exit 1
fi
SHA256=$(sha256sum "$INSTALLER_PATH" | cut -d' ' -f1)
echo "sha256=$SHA256" >> $GITHUB_OUTPUT
echo "Computed SHA256: $SHA256"
- name: Notify ACFS
uses: peter-evans/repository-dispatch@v4
with:
token: ${{ secrets.ACFS_DISPATCH_TOKEN }}
repository: Dicklesworthstone/agentic_coding_flywheel_setup
event-type: installer-updated
client-payload: |
{
"tool": "${{ github.event.repository.name }}",
"repo": "${{ github.repository }}",
"commit": "${{ github.sha }}",
"new_sha256": "${{ steps.checksum.outputs.sha256 }}",
"ref": "${{ github.ref }}",
"actor": "${{ github.actor }}"
}
- name: Log notification
run: |
echo "::notice::Notified ACFS about installer change"
echo "Repository: ${{ github.repository }}"
echo "Commit: ${{ github.sha }}"
echo "SHA256: ${{ steps.checksum.outputs.sha256 }}"
# Validate installer syntax on PRs
validate-installer:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Shellcheck installer
run: |
EXIT_CODE=0
for script in install.sh scripts/install.sh; do
if [ -f "$script" ]; then
echo "Checking $script..."
shellcheck "$script" || EXIT_CODE=1
fi
done
exit $EXIT_CODE
name: release-automation
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
jobs:
tag-release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Read version from Cargo.toml
id: version
run: |
version=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
if [ -z "$version" ]; then
echo "::error::Unable to parse version from Cargo.toml"
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Read latest tag
id: latest
run: |
latest=$(git tag --list 'v*' --sort=-v:refname | head -1)
echo "latest=$latest" >> "$GITHUB_OUTPUT"
- name: Create tag
if: steps.latest.outputs.latest != format('v{0}', steps.version.outputs.version)
run: |
tag="v${{ steps.version.outputs.version }}"
echo "Creating release tag $tag"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag -a "$tag" -m "Release $tag"
git push origin "$tag"
- name: Skip (already tagged)
if: steps.latest.outputs.latest == format('v{0}', steps.version.outputs.version)
run: |
echo "Latest tag matches Cargo.toml version; nothing to do."
# Destructive Command Guard - PR Diff Scanning
#
# This workflow scans PR changes for destructive commands in executable contexts
# (shell scripts, Dockerfiles, GitHub Actions workflows, etc.)
#
# USAGE:
# 1. Copy this file to .github/workflows/scan.yml in your repository
# 2. Ensure dcg is built or available (this workflow builds from source)
# 3. Customize the fail-on policy if needed (default: error-only)
#
# OPTIONS:
# - fail-on: error (default), warning, or none
# - redact: none, quoted, or aggressive (for sensitive commands)
#
# For more information: https://github.com/Dicklesworthstone/destructive_command_guard
name: scan
on:
pull_request:
types: [opened, synchronize, reopened]
env:
CARGO_TERM_COLOR: always
jobs:
scan-pr:
name: Scan PR for destructive commands
runs-on: ubuntu-latest
# Allow the job to add PR comments and annotations
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
# Fetch enough history for git diff comparison
fetch-depth: 0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@nightly
- name: Cache cargo registry and target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-scan-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-scan-
- name: Build dcg
run: cargo build --release
- name: Determine base ref
id: base
run: |
# Use PR base SHA for diff comparison
BASE_REF="${{ github.event.pull_request.base.sha }}"
echo "ref=${BASE_REF}" >> $GITHUB_OUTPUT
echo "Base ref: ${BASE_REF}"
- name: Run dcg scan on PR diff
id: scan
run: |
set +e # Don't exit on non-zero
# Run scan and capture output
# Note: stdout goes to JSON file, stderr logged separately to avoid corrupting JSON
./target/release/dcg scan \
--git-diff "${{ steps.base.outputs.ref }}...HEAD" \
--format json \
--fail-on error \
--max-findings 50 \
--truncate 200 \
> scan-results.json 2>scan-stderr.log
EXIT_CODE=$?
# Show any stderr output in the logs (for debugging)
if [ -s scan-stderr.log ]; then
echo "::group::dcg stderr output"
cat scan-stderr.log
echo "::endgroup::"
fi
echo "exit_code=${EXIT_CODE}" >> $GITHUB_OUTPUT
# Generate summary for GitHub Actions
echo "## Destructive Command Scan Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -f scan-results.json ] && [ -s scan-results.json ]; then
# Extract summary stats (with validation to ensure integers)
FILES_SCANNED=$(jq -r '.summary.files_scanned // 0' scan-results.json 2>/dev/null)
FINDINGS_TOTAL=$(jq -r '.summary.findings_total // 0' scan-results.json 2>/dev/null)
ERRORS=$(jq -r '.summary.severities.error // 0' scan-results.json 2>/dev/null)
WARNINGS=$(jq -r '.summary.severities.warning // 0' scan-results.json 2>/dev/null)
# Validate that values are integers (default to 0 if not)
[[ "${FILES_SCANNED}" =~ ^[0-9]+$ ]] || FILES_SCANNED=0
[[ "${FINDINGS_TOTAL}" =~ ^[0-9]+$ ]] || FINDINGS_TOTAL=0
[[ "${ERRORS}" =~ ^[0-9]+$ ]] || ERRORS=0
[[ "${WARNINGS}" =~ ^[0-9]+$ ]] || WARNINGS=0
echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Files scanned | ${FILES_SCANNED} |" >> $GITHUB_STEP_SUMMARY
echo "| Total findings | ${FINDINGS_TOTAL} |" >> $GITHUB_STEP_SUMMARY
echo "| Errors | ${ERRORS} |" >> $GITHUB_STEP_SUMMARY
echo "| Warnings | ${WARNINGS} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Show findings if any exist
if [ "${FINDINGS_TOTAL}" -gt 0 ]; then
echo "### Findings" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
jq -r '.findings[] | "\(.file):\(.line) [\(.severity)] \(.rule_id // .extractor_id): \(.reason // "no reason")"' scan-results.json | head -20 >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
if [ "${FINDINGS_TOTAL}" -gt 20 ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "_... and $((FINDINGS_TOTAL - 20)) more findings (see artifact for full report)_" >> $GITHUB_STEP_SUMMARY
fi
else
echo "No destructive commands found in changed files." >> $GITHUB_STEP_SUMMARY
fi
else
echo "Scan output not available" >> $GITHUB_STEP_SUMMARY
fi
# Don't exit with error here - let the "Check scan result" step handle failure
# This ensures artifact upload completes before job fails
- name: Upload scan results
if: always()
uses: actions/upload-artifact@v7
with:
name: scan-results
path: scan-results.json
retention-days: 14
if-no-files-found: ignore
- name: Check scan result
if: steps.scan.outputs.exit_code != '0'
run: |
echo "::error::Destructive commands detected in PR diff. Review the scan results above."
# Show findings in annotations (only if valid JSON file exists)
if [ -f scan-results.json ] && [ -s scan-results.json ]; then
jq -r '.findings[] | select(.severity == "error") | "::error file=\(.file),line=\(.line)::\(.rule_id // .extractor_id): \(.reason // "destructive command detected")"' scan-results.json 2>/dev/null || true
fi
exit 1
# Rust build artifacts (including symlinks to shared target dirs)
/target
/target/
**/*.rs.bk
# Note: Cargo.lock is committed for binary crates per Rust best practices
# Stray compiler output
a.out
*.o
rustc-ice-*.txt
# Editor/IDE artifacts
.DS_Store
.idea/
.vscode/
*.swp
*.swo
*~
# Distribution artifacts
/dist/
# AI coding tool artifacts
.aider.chat.history.md
.aider.input.history
.aider.tags.cache.v3/
.claude/
# bv (beads viewer) local config and caches
.bv/
# Temporary files
temp_*.rs
*.tmp
# Local backup directories
.local_backup_*/
# Environment files
.env
.env.local
# Coverage artifacts
lcov.info
coverage-summary.txt
tarpaulin-report.html
# Test artifacts
test-artifacts/
junit.xml
proptest-regressions/
# Ephemeral/temporary files (agent workflow artifacts)
RESEARCH_FINDINGS.md
TOON_INTEGRATION_BRIEF.md
cov_*.out
*.snap.new
sweep_*.png
test_screenshot.png
target-*/
# Runtime SQLite databases
storage.sqlite3
storage.sqlite3-*
*.sqlite3.bak
# Remote compilation helper artifacts
.rch-target/
.rch-deploy-root/
rch-bin/
rch-install/
# Test artifacts
hello_write.txt
# Agent mail project identity
.agent-mail-project-id
# Beads write lock (per-process, regenerated on each br invocation)
.beads/.write.lock
# E2E test run logs
e2e_*.log
# NTM agent coordination runtime state (human inbox spool, per-pane rate
# limits, robot snapshot summaries) — regenerated every orchestrator tick.
.ntm/
ERROR: bd is disabled. Use 'br' instead.
bd daemons have been permanently blocked.
[supervisor] daemon exited with error: exit status 1
[supervisor] restarting in 1s (attempt 1/5)
ERROR: bd is disabled. Use 'br' instead.
bd daemons have been permanently blocked.
[supervisor] daemon exited with error: exit status 1
[supervisor] restarting in 2s (attempt 2/5)
ERROR: bd is disabled. Use 'br' instead.
bd daemons have been permanently blocked.
[supervisor] daemon exited with error: exit status 1
[supervisor] restarting in 4s (attempt 3/5)
ERROR: bd is disabled. Use 'br' instead.
bd daemons have been permanently blocked.
[supervisor] daemon exited with error: exit status 1
[supervisor] restarting in 8s (attempt 4/5)
ERROR: bd is disabled. Use 'br' instead.
bd daemons have been permanently blocked.
[supervisor] daemon exited with error: exit status 1
[supervisor] restarting in 16s (attempt 5/5)
ERROR: bd is disabled. Use 'br' instead.
bd daemons have been permanently blocked.
[supervisor] daemon exited with error: exit status 1
[supervisor] max restarts (5) exceeded, not restarting
error: unrecognized subcommand 'daemon'
Usage: bd [OPTIONS] <COMMAND>
For more information, try '--help'.
[supervisor] daemon exited with error: exit status 2
[supervisor] restarting in 1s (attempt 1/5)
error: unrecognized subcommand 'daemon'
Usage: bd [OPTIONS] <COMMAND>
For more information, try '--help'.
[supervisor] daemon exited with error: exit status 2
[supervisor] restarting in 2s (attempt 2/5)
error: unrecognized subcommand 'daemon'
Usage: bd [OPTIONS] <COMMAND>
For more information, try '--help'.
[supervisor] daemon exited with error: exit status 2
[supervisor] restarting in 4s (attempt 3/5)
error: unrecognized subcommand 'daemon'
Usage: bd [OPTIONS] <COMMAND>
For more information, try '--help'.
[supervisor] daemon exited with error: exit status 2
[supervisor] restarting in 8s (attempt 4/5)
error: unrecognized subcommand 'daemon'
Usage: bd [OPTIONS] <COMMAND>
For more information, try '--help'.
[supervisor] daemon exited with error: exit status 2
[supervisor] restarting in 16s (attempt 5/5)
error: unrecognized subcommand 'daemon'
Usage: bd [OPTIONS] <COMMAND>
For more information, try '--help'.
[supervisor] daemon exited with error: exit status 2
[supervisor] max restarts (5) exceeded, not restarting
error: unrecognized subcommand 'daemon'
Usage: bd [OPTIONS] <COMMAND>
For more information, try '--help'.
[supervisor] daemon exited with error: exit status 2
[supervisor] restarting in 1s (attempt 1/5)
error: unrecognized subcommand 'daemon'
Usage: bd [OPTIONS] <COMMAND>
For more information, try '--help'.
[supervisor] daemon exited with error: exit status 2
[supervisor] restarting in 2s (attempt 2/5)
error: unrecognized subcommand 'daemon'
Usage: bd [OPTIONS] <COMMAND>
For more information, try '--help'.
[supervisor] daemon exited with error: exit status 2
[supervisor] restarting in 4s (attempt 3/5)
error: unrecognized subcommand 'daemon'
Usage: bd [OPTIONS] <COMMAND>
For more information, try '--help'.
[supervisor] daemon exited with error: exit status 2
[supervisor] restarting in 8s (attempt 4/5)
error: unrecognized subcommand 'daemon'
Usage: bd [OPTIONS] <COMMAND>
For more information, try '--help'.
[supervisor] daemon exited with error: exit status 2
[supervisor] restarting in 16s (attempt 5/5)
error: unrecognized subcommand 'daemon'
Usage: bd [OPTIONS] <COMMAND>
For more information, try '--help'.
[supervisor] daemon exited with error: exit status 2
[supervisor] max restarts (5) exceeded, not restarting
[cm] MCP HTTP server listening on http://127.0.0.1:44965
[cm] WARNING: Transport is HTTP-only; stdio/SSE are intentionally disabled.
[cm] MCP HTTP server listening on http://127.0.0.1:46019
[cm] WARNING: Transport is HTTP-only; stdio/SSE are intentionally disabled.
[cm] MCP HTTP server listening on http://127.0.0.1:44023
[cm] WARNING: Transport is HTTP-only; stdio/SSE are intentionally disabled.
# UBS ignore file for DCG
# Exclude test, benchmark, and generated code
# Test code
tests/
*_test.rs
*_tests.rs
# Benchmarks
benches/
# Fuzz targets
fuzz/
# Build artifacts
target/
# Generated files
*.generated.rs
# Vendored dependencies
vendor/
# Destructive Command Guard - GitHub Action
#
# Scans your repository for destructive commands in scripts, Dockerfiles,
# GitHub Actions workflows, and other executable contexts.
#
# USAGE:
# - uses: Dicklesworthstone/destructive_command_guard/action@v0
# with:
# fail-on: error
# paths: .
#
# For more information: https://github.com/Dicklesworthstone/destructive_command_guard
name: 'Destructive Command Guard Scan'
description: 'Scan repository for destructive commands in executable contexts'
author: 'Dicklesworthstone'
branding:
icon: 'shield'
color: 'red'
inputs:
paths:
description: 'Paths to scan (space-separated, or use git-diff mode)'
required: false
default: '.'
git-diff:
description: 'Git ref range for diff-based scanning (e.g., "origin/main...HEAD")'
required: false
default: ''
fail-on:
description: 'Severity threshold for failure: error, warning, or none'
required: false
default: 'error'
format:
description: 'Output format: json, pretty, compact, or markdown'
required: false
default: 'json'
max-findings:
description: 'Maximum findings to report (0 for unlimited)'
required: false
default: '100'
truncate:
description: 'Maximum command preview length'
required: false
default: '200'
comment-on-pr:
description: 'Post scan results as PR comment (requires pull-requests: write permission)'
required: false
default: 'false'
dcg-version:
description: 'DCG version to use (default: latest release)'
required: false
default: 'latest'
outputs:
exit-code:
description: 'Exit code from dcg scan (0 = clean, non-zero = findings)'
value: ${{ steps.scan.outputs.exit_code }}
files-scanned:
description: 'Number of files scanned'
value: ${{ steps.parse.outputs.files_scanned }}
findings-total:
description: 'Total number of findings'
value: ${{ steps.parse.outputs.findings_total }}
errors:
description: 'Number of error-severity findings'
value: ${{ steps.parse.outputs.errors }}
warnings:
description: 'Number of warning-severity findings'
value: ${{ steps.parse.outputs.warnings }}
results-file:
description: 'Path to JSON results file'
value: ${{ steps.scan.outputs.results_file }}
runs:
using: 'composite'
steps:
- name: Download DCG binary
id: download
shell: bash
run: |
set -euo pipefail
VERSION="${{ inputs.dcg-version }}"
if [ "$VERSION" = "latest" ]; then
# Get latest release tag
VERSION=$(curl -sL https://api.github.com/repos/Dicklesworthstone/destructive_command_guard/releases/latest | jq -r '.tag_name // "v0.2.7"')
fi
echo "Installing DCG version: $VERSION"
# Determine platform
case "$(uname -s)-$(uname -m)" in
Linux-x86_64) PLATFORM="x86_64-unknown-linux-gnu" ;;
Linux-aarch64) PLATFORM="aarch64-unknown-linux-gnu" ;;
Darwin-x86_64) PLATFORM="x86_64-apple-darwin" ;;
Darwin-arm64) PLATFORM="aarch64-apple-darwin" ;;
*)
echo "::error::Unsupported platform: $(uname -s)-$(uname -m)"
exit 1
;;
esac
# Download and extract
DOWNLOAD_URL="https://github.com/Dicklesworthstone/destructive_command_guard/releases/download/${VERSION}/dcg-${PLATFORM}.tar.gz"
echo "Downloading from: $DOWNLOAD_URL"
# Create temp directory for extraction
mkdir -p "${{ runner.temp }}/dcg"
cd "${{ runner.temp }}/dcg"
# Download with retry
for i in 1 2 3; do
if curl -sL --fail "$DOWNLOAD_URL" -o dcg.tar.gz; then
break
fi
if [ $i -eq 3 ]; then
echo "::warning::Could not download pre-built binary, falling back to cargo install"
cargo install --git https://github.com/Dicklesworthstone/destructive_command_guard --tag "$VERSION" || cargo install --git https://github.com/Dicklesworthstone/destructive_command_guard
echo "dcg_path=$(which dcg)" >> $GITHUB_OUTPUT
exit 0
fi
sleep 2
done
tar -xzf dcg.tar.gz
chmod +x dcg
echo "dcg_path=${{ runner.temp }}/dcg/dcg" >> $GITHUB_OUTPUT
echo "DCG installed successfully"
- name: Run DCG scan
id: scan
shell: bash
run: |
set -uo pipefail
DCG="${{ steps.download.outputs.dcg_path }}"
RESULTS_FILE="${{ runner.temp }}/dcg-scan-results.json"
# Build command arguments
ARGS=(
scan
--format "${{ inputs.format }}"
--fail-on "${{ inputs.fail-on }}"
--max-findings "${{ inputs.max-findings }}"
--truncate "${{ inputs.truncate }}"
)
# Add paths or git-diff
if [ -n "${{ inputs.git-diff }}" ]; then
ARGS+=(--git-diff "${{ inputs.git-diff }}")
else
ARGS+=(--paths ${{ inputs.paths }})
fi
echo "Running: $DCG ${ARGS[*]}"
# Run scan and capture exit code
set +e
"$DCG" "${ARGS[@]}" > "$RESULTS_FILE" 2>dcg-stderr.log
EXIT_CODE=$?
set -e
# Log stderr if any
if [ -s dcg-stderr.log ]; then
echo "::group::DCG stderr"
cat dcg-stderr.log
echo "::endgroup::"
fi
echo "exit_code=$EXIT_CODE" >> $GITHUB_OUTPUT
echo "results_file=$RESULTS_FILE" >> $GITHUB_OUTPUT
- name: Parse scan results
id: parse
shell: bash
run: |
RESULTS_FILE="${{ steps.scan.outputs.results_file }}"
if [ -f "$RESULTS_FILE" ] && [ -s "$RESULTS_FILE" ]; then
FILES_SCANNED=$(jq -r '.summary.files_scanned // 0' "$RESULTS_FILE" 2>/dev/null || echo 0)
FINDINGS_TOTAL=$(jq -r '.summary.findings_total // 0' "$RESULTS_FILE" 2>/dev/null || echo 0)
ERRORS=$(jq -r '.summary.severities.error // 0' "$RESULTS_FILE" 2>/dev/null || echo 0)
WARNINGS=$(jq -r '.summary.severities.warning // 0' "$RESULTS_FILE" 2>/dev/null || echo 0)
else
FILES_SCANNED=0
FINDINGS_TOTAL=0
ERRORS=0
WARNINGS=0
fi
echo "files_scanned=$FILES_SCANNED" >> $GITHUB_OUTPUT
echo "findings_total=$FINDINGS_TOTAL" >> $GITHUB_OUTPUT
echo "errors=$ERRORS" >> $GITHUB_OUTPUT
echo "warnings=$WARNINGS" >> $GITHUB_OUTPUT
- name: Generate step summary
shell: bash
run: |
RESULTS_FILE="${{ steps.scan.outputs.results_file }}"
EXIT_CODE="${{ steps.scan.outputs.exit_code }}"
echo "## Destructive Command Guard Scan" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$EXIT_CODE" = "0" ]; then
echo ":white_check_mark: **No destructive commands detected**" >> $GITHUB_STEP_SUMMARY
else
echo ":warning: **Destructive commands detected**" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Files scanned | ${{ steps.parse.outputs.files_scanned }} |" >> $GITHUB_STEP_SUMMARY
echo "| Total findings | ${{ steps.parse.outputs.findings_total }} |" >> $GITHUB_STEP_SUMMARY
echo "| Errors | ${{ steps.parse.outputs.errors }} |" >> $GITHUB_STEP_SUMMARY
echo "| Warnings | ${{ steps.parse.outputs.warnings }} |" >> $GITHUB_STEP_SUMMARY
# Show top findings if any
if [ -f "$RESULTS_FILE" ] && [ -s "$RESULTS_FILE" ]; then
FINDINGS_TOTAL=$(jq -r '.summary.findings_total // 0' "$RESULTS_FILE" 2>/dev/null || echo 0)
if [ "$FINDINGS_TOTAL" -gt 0 ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Top Findings" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
jq -r '.findings[:10][] | "\(.file):\(.line) [\(.severity)] \(.rule_id // .extractor_id)"' "$RESULTS_FILE" 2>/dev/null >> $GITHUB_STEP_SUMMARY || true
echo '```' >> $GITHUB_STEP_SUMMARY
fi
fi
- name: Post PR comment
if: inputs.comment-on-pr == 'true' && github.event_name == 'pull_request'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
RESULTS_FILE="${{ steps.scan.outputs.results_file }}"
FINDINGS_TOTAL="${{ steps.parse.outputs.findings_total }}"
# Build comment body
COMMENT="## :shield: Destructive Command Guard Scan\n\n"
if [ "${{ steps.scan.outputs.exit_code }}" = "0" ]; then
COMMENT+="**No destructive commands detected** :white_check_mark:\n\n"
else
COMMENT+="**$FINDINGS_TOTAL findings detected** :warning:\n\n"
fi
COMMENT+="| Metric | Count |\n"
COMMENT+="|--------|-------|\n"
COMMENT+="| Files scanned | ${{ steps.parse.outputs.files_scanned }} |\n"
COMMENT+="| Errors | ${{ steps.parse.outputs.errors }} |\n"
COMMENT+="| Warnings | ${{ steps.parse.outputs.warnings }} |\n"
if [ "$FINDINGS_TOTAL" -gt 0 ] && [ -f "$RESULTS_FILE" ]; then
COMMENT+="\n<details><summary>View findings</summary>\n\n\`\`\`\n"
COMMENT+=$(jq -r '.findings[:20][] | "\(.file):\(.line) [\(.severity)] \(.rule_id // .extractor_id): \(.reason // "")"' "$RESULTS_FILE" 2>/dev/null | head -40)
COMMENT+="\n\`\`\`\n</details>\n"
fi
echo -e "$COMMENT" | gh pr comment "${{ github.event.pull_request.number }}" --body-file -
- name: Fail on findings
if: steps.scan.outputs.exit_code != '0'
shell: bash
run: |
echo "::error::DCG scan found destructive commands. Review the findings above."
exit ${{ steps.scan.outputs.exit_code }}
Destructive Command Guard - GitHub Action
A GitHub Action that scans your repository for destructive commands in executable contexts (shell scripts, Dockerfiles, GitHub Actions workflows, CI configs, etc.).
Quick Start
Add to your workflow:
name: Security Scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # Required for PR comments
steps:
- uses: actions/checkout@v4
- uses: Dicklesworthstone/destructive_command_guard/action@v0
with:
fail-on: errorInputs
| Input | Description | Default |
|---|---|---|
paths | Paths to scan (space-separated) | . |
git-diff | Git ref range for diff scanning (e.g., origin/main...HEAD) | |
fail-on | Severity threshold: error, warning, or none | error |
format | Output format: json, pretty, compact, markdown | json |
max-findings | Maximum findings to report (0 = unlimited) | 100 |
truncate | Maximum command preview length | 200 |
comment-on-pr | Post results as PR comment | false |
dcg-version | DCG version to use | latest |
Outputs
| Output | Description |
|---|---|
exit-code | Exit code (0 = clean, non-zero = findings) |
files-scanned | Number of files scanned |
findings-total | Total findings count |
errors | Error-severity findings count |
warnings | Warning-severity findings count |
results-file | Path to JSON results file |
Examples
PR Diff Scanning
Scan only changed files in a pull request:
- uses: Dicklesworthstone/destructive_command_guard/action@v0
with:
git-diff: ${{ github.event.pull_request.base.sha }}...HEAD
comment-on-pr: trueFull Repository Scan
Scan the entire repository:
- uses: Dicklesworthstone/destructive_command_guard/action@v0
with:
paths: .
fail-on: warningScan Specific Directories
- uses: Dicklesworthstone/destructive_command_guard/action@v0
with:
paths: scripts/ .github/workflows/Use Results in Subsequent Steps
- uses: Dicklesworthstone/destructive_command_guard/action@v0
id: scan
with:
fail-on: none # Don't fail, just report
- name: Check results
if: steps.scan.outputs.findings-total > 0
run: |
echo "Found ${{ steps.scan.outputs.findings-total }} issues"
cat ${{ steps.scan.outputs.results-file }}Pin to Specific Version
- uses: Dicklesworthstone/destructive_command_guard/action@v0
with:
dcg-version: v0.2.7What Gets Scanned
The action scans for destructive commands in:
- Shell scripts (
.sh,.bash,.zsh) - Dockerfiles
- GitHub Actions workflows (
.yml,.yaml) - GitLab CI configs (
.gitlab-ci.yml) - Makefiles
- Package.json scripts
- Docker Compose files
- Terraform provisioners (
.tf)
License
MIT
{
"schema_version": 1,
"checks": [
{
"id": "binary_path",
"name": "Binary in PATH",
"status": "ok",
"message": "dcg found in PATH"
},
{
"id": "claude_settings",
"name": "Claude Code settings file",
"status": "ok",
"message": "settings.json found at /home/ubuntu/.claude/settings.json"
},
{
"id": "hook_wiring",
"name": "Hook wiring",
"status": "error",
"message": "dcg hook not registered",
"remediation": "Run 'dcg install' to register the hook"
},
{
"id": "config",
"name": "Configuration",
"status": "ok",
"message": "Config valid at /home/ubuntu/.config/dcg/config.toml"
},
{
"id": "packs",
"name": "Pattern packs",
"status": "ok",
"message": "3 packs enabled"
},
{
"id": "smoke_test",
"name": "Evaluator smoke test",
"status": "ok",
"message": "Evaluator smoke test passed"
},
{
"id": "observe_mode",
"name": "Observe mode",
"status": "ok",
"message": "Observe mode disabled"
},
{
"id": "allowlists",
"name": "Allowlists",
"status": "ok",
"message": "Allowlist layers found: 1"
}
],
"issues": 1,
"fixed": 0,
"ok": false
}
{
"command": "git reset --hard",
"decision": "deny",
"rule_id": "core.git:reset-hard",
"pack_id": "core.git",
"pattern_name": "reset-hard",
"reason": "git reset --hard destroys uncommitted changes. Use 'git stash' first.",
"source": "pack",
"matched_span": [
0,
16
],
"agent": {
"detected": "unknown",
"trust_level": "medium",
"detection_method": "none"
}
}