cmdguard is now non-interactive by design. The entire interactive
confirmation layer (TTY prompts, readLineWithTimeout, double-confirm,
CMDGUARD_NONINTERACTIVE env var, [guard] config section with
confirm_timeout/confirm_double_timeout) has been removed.
The protection model is now exactly three levels:
| Level | Icon | Behaviour |
|---|---|---|
| reject | 🚫 | Refused + logged. --bypass cannot override. Exit 1. |
| guarded | 🔒 | Without --bypass: refused + logged (exit 1). With valid --bypass: backup → log → execute. |
| allow | ✅ | No rule matched. Logged + executed directly. |
The bypass identifier format changed from
<host>/<platform>/<agent>/<task> (4 segments) to
<platform>/<agent>/<task> (3 segments). The host segment was
redundant with platform in practice.
- Minimum length: 10 characters (was 12).
- Placeholder blacklist updated:
hostadded; all tokens checked as whole-segment equality (case-insensitive), not substrings. - Help text and error messages updated with the new format and examples.
confirm_double, confirm, and warn are still accepted in config
files and per-command overrides. During Load(), their values are
merged into guarded. This means old config files continue to work
without modification - they just map to the new 2-level protection
(reject + guarded).
CMDGUARD_NONINTERACTIVEenv var (cmdguard is always non-interactive now)CMDGUARD_AGENT_MODEenv var (never released; was only proposed in docs)[guard]config section (confirm_timeout,confirm_double_timeout)- Interactive TTY confirmation flow (
readLineWithTimeout, single/double confirm) confirmandconfirm_doubleas active protection levels (kept as legacy aliases for reading old logs/configs)agent_modeconfig field- Net deletion: ~627 lines across 33 files
All three levels now write to the audit log:
| Level | action field |
bypass field |
Notes |
|---|---|---|---|
| reject | reject |
(empty) | Includes rule + message |
| guarded (no bypass) | reject |
(empty) | Message: "rejected: guarded path, no --bypass provided" |
| guarded (invalid bypass) | reject |
(empty) | Message: "rejected: invalid --bypass identifier" |
| guarded (valid bypass) | guarded |
<identifier> |
Full bypass ID recorded for traceability |
| allow | allow |
(empty) | Message: "no matching rule, allowed" |
When a vault backup fails, the error output now includes the vault
directory path and a list of common causes (permissions, disk full,
sandbox restriction), plus a cmdguard path hint for further
diagnosis. Previously the output was a single line with no
remediation guidance, making it hard for agents and humans to
determine the root cause.
Code, tests, comments, help text, README (en + zh), docs (commands, configuration, vault - en + zh), example config, init template, and e2e tests all updated to reflect the new 3-level model.
A hardening release: no new user-facing features, but three real bugs
found by expanding the static-analysis linter set, plus a sweep of
Go 1.22–1.26 modernisation and tighter file permissions across the
entire ~/.cmdguard tree.
- Dead
reasonTimeoutenum value. ThenonTTYReasontype declaredreasonTimeoutbut theemitNonTTYRejectionswitch never handled it — timeout rejection goes through a separate function. Removed the dead value;exhaustivenow verifies everyswitchon the type covers all declared cases. - Duplicate "agent" in bypass help text. The 4-segment identifier
table printed
agent agent id, whichdupwordflagged. Changed toagent unique identifier. - Possible nil dereference after
os.Exit.staticcheck SA5011reported that after theif entry == nil { os.Exit(1) }guard,entry.Expireddereferences a pointer that the analyser cannot prove is non-nil (because it doesn't knowos.Exitnever returns). Added an unreachablereturnto close the path.
Every file and directory under ~/.cmdguard is now owner-only:
| Path | Before | After |
|---|---|---|
| vault root dir | 0755 | 0700 |
| vault per-backup dir | 0755 | 0700 |
| vault files/ subdirs | 0755 | 0700 |
| manifest.json | 0644 | 0600 |
| backup/ dir (init --force) | 0755 | 0700 |
| config dir / bin / log / vault | 0755 | 0700 |
| config.toml | 0644 | 0600 |
| wrapper scripts (rm/mv/chmod) | 0755 | 0700 |
| audit log | 0644 | 0600 |
Rationale: the vault holds copies of files the user just deleted
(~/.ssh/id_rsa, /etc/shadow, …). A world-readable manifest
advertising the existence of those originals defeats the purpose
of deleting them.
strings.HasPrefix+TrimPrefix→strings.CutPrefixstrings.HasSuffix+TrimSuffix→strings.CutSuffixstrings.Split→strings.SplitSeq(lazy iteration)- manual loop →
slices.Contains if/elseclamp →min()builtininterface{}→anywg.Add(1); go func(){ defer wg.Done(); … }()→wg.Go(func(){ … })for i := 0; i < N; i++→for i := range Nfmt.Errorf("literal")→errors.Newfmt.Sprintf("%d", n)→strconv.Itoa(n)string +=in loop →strings.Builder
.golangci.yml expanded from 5 linters to 37 (of 113 available).
The new additions caught the three bugs listed above, plus a batch of
modernisation and performance issues. govulncheck reports no known
vulnerabilities.
gosec false positives (cmdguard intentionally launches user-supplied
commands and reads variable paths) are suppressed per-site with
// #nosec directives, each with a justification comment.
Big jump from v0.6.0 to mark the work density: four new features
(each one number) plus two rounds of bug-fix sweeps (each one
number) — 0.6 + 4 + 2 = 0.12. There's no v0.7..v0.11 between
this and v0.6.0; that version space stays reserved.
A reliability and correctness release. No new user-facing features beyond two convenience subcommands and a few config-view flags; the bulk of the work is fixing subtle bugs and tightening invariants found during a five-round release sweep.
Lists every backup currently in the vault: ID, command, age,
expiration, and the original target path. Complements vault clean
(which already showed expired backups via --dry-run) by giving
users a full inventory.
$ cmdguard vault list
[cmdguard] 2 backup(s) (vault size: 11.2 KB)
ID Command Created Expires Target
abc123def456 rm 2026-06-19 09:01 in 6d 23h /etc/important.conf
fed987cba210 mv 2026-06-19 10:14 in 6d 23h /opt/data/old.bin
--json emits a machine-readable form for tooling.
Prints cmdguard's directory layout — config file, log dir, vault
dir, bin dir — with file counts and sizes. Replaces the implicit
"go look in ~/.cmdguard" knowledge users used to need:
$ cmdguard path
[cmdguard] cmdguard paths
Config dir: /home/alice/.cmdguard
Config file: /home/alice/.cmdguard/config.toml (327 B, 24 lines)
Log dir: /home/alice/.cmdguard/log
- 2026-06-19.log (4.1 KB)
- 2026-06-18.log (1.8 KB)
(2 file(s))
Vault dir: /home/alice/.cmdguard/vault (11.2 KB, 2 backup(s))
Bin dir: /home/alice/.cmdguard/bin
- rm, mv, chmod (3 file(s))
The log section caps display at 5 newest files to keep output bounded on long-running installs.
--defaultshows the built-in defaults verbatim (what you get if you never wrote~/.cmdguard/config.toml).--rawdumps the rawconfig.tomlcontent with a header showing the path. Equivalent tocat ~/.cmdguard/config.tomlbut keeps the user inside the cmdguard CLI when checking what's actually on disk vs. what's effective.--bin-dirprints just the wrapper-script directory as a bare path. Designed for shell composition: the integration guide now recommendsexport PATH="$(cmdguard config --bin-dir):$PATH"instead of asking users to hardcode~/.cmdguard/bin.
The three flags are mutually exclusive with each other and with
the bare cmdguard config (which still shows the merged effective
view).
PATH=$HOME/.cmdguard/bin:... was the documented integration. With
v0.6.0 binaries, the wrapper at ~/.cmdguard/bin/rm would invoke
cmdguard guard rm <args>, which used a basename search to find
the "real" rm. If cmdguard itself was earlier in PATH than
/bin, that basename search returned the wrapper again, and the
process forked into infinite recursion until OS limits killed it.
Fix: the wrapper template now contains a sentinel comment
# cmdguard:wrapper:v1 on its first line. findRealCommand reads
the first 256 bytes of each candidate and skips any file containing
the sentinel. Detection is purely textual — no SameFile chains, no
hardcoded paths — so it works regardless of where cmdguard or the
wrappers live.
Existing v0.6.0 deployments can be repaired by running
cmdguard init --force (recreates wrappers with sentinel) or
manually appending the sentinel line to each wrapper script. The
sentinel is a comment, so older v0.6.0 binaries treat upgraded
wrappers as harmless.
Test coverage: TestIsCmdguardWrapper (8 sub-tests) exercises
sentinel-present, sentinel-absent, broken-shebang, large-file,
no-read-permission, and not-a-regular-file cases.
-
mv ... existing-dir/lost the destination's existing files. When the destination directory already contained files that matched basenames of the source files,mvoverwrote them with no warning and no backup. Now backed up before the rename, with log entries pointing at the clobbered originals. -
Vault same-basename collisions. Backups are now keyed by full original path (recorded in
manifest.json), not just basename. Restoring/etc/foo.confno longer accidentally overwrites/opt/foo.confor vice versa. Legacy basename-keyed backups (pre-manifest) still restore correctly via fallback. -
matchPath/**boundary./etc/**previously matched/etcd/foobecause of a missing path-separator check. Fuzz tests added to lock the invariant. -
Audit log durability.
f.Sync()after every audit-log write, so a crash or power loss between operations doesn't lose entries that the user has already seen confirmation for. -
Vault file permissions preserved. Backup files retained the default 0600 permissions; on restore, that didn't always match the original file's mode. Now stored alongside the file in the manifest and applied on restore.
-
Log entry IDs. Switched from time-based to
crypto/randgeneration. Theoretical-only collision risk, but cheap to fix. -
Double-formatted error messages. A handful of error paths ran
Sprintfonce in the message constant and once at the call site, producing strings like"failed: %v: <error>". Cleaned up by introducingerrExit/errPrint/warnhelpers that own the[cmdguard]tag anderror:/warning:prefix; templates no longer embed those affixes. -
cmdguard list --since 7daysnow errors out withinvalid --since value "7days" (use formats like 30m, 2h, 7d). Previously it parsed to zero and silently returned the entire unfiltered log. -
Unknown flags and unexpected arguments rejected.
list,undo,config,init,path,vault clean, andvault listnow exit 1 on any unrecognised token. The diagnostic distinguishes the two cases:unknown flag "--recnet" for 'list' subcommand— catches typos like--recnet 5(intended--recent 5) that previously silently fell through to defaults.unexpected argument "foo" for 'init' subcommand— for bare positional garbage that isn't a flag at all.--recent/--idand friends also now require a value: missing values, non-integer or non-positive--recent, etc., all error out instead of silently using a default.
The guarded commands (
rm/mv/chmod) deliberately keep their permissive passthrough — they need to forward-rf,-R, etc. to the real binary.Regression coverage:
e2e_flags_test.goruns the full rejection table (23 cases) plus--*=valueequals-form acceptance against the real binary in a subprocess. -
cmdguard vaultwithout a subcommand now prints a usage block and exits 1, instead of runningclean. A destructive verb shouldn't be the silent default. -
undoerror messages route to stderr. Six call sites (no-args usage, ID-not-found, expired, rejected, backup-not-found, per-file restore failure) moved fromfmt.Printlntofmt.Fprintln(os.Stderr, ...). Success-path messages (restored N file(s), dry-run header) stay on stdout.
- Centralised the protected-command list in
internal/subcmd/consts.govia aGuardedCommands()function. Single source of truth; was previously duplicated acrossinit,guard, and the wrapper template. findRealCommandrewritten:filepath.SplitList(PATH)instead of manual splitting, early-skip for cmdguard's own bin dir, memoisedos.Statof self.- Extracted
shouldFallThroughForMissing,autoPurgeIfEnabled, andappendLoghelpers fromRunGuardto reduce a 200-line function to digestible pieces. - Removed dead code (unused
formatTargets, etc.) and tests with hardcoded/Users/<name>paths. - Fixed a file-handle leak in
backupFileson the rare error path whereio.Copyfailed mid-stream. placeholderTokensfor--bypassvalidation tightened: previous list missed a few common stand-ins (changeme,todo).
retention_daysdefault reduced from 30 to 7, covering weekend scenarios while keeping storage manageable. Synced across all docs, example config, test assertions, and generatedinitoutput.
[protect]section present → config file is the source of truth for protection rules (no default merging). Users who write[protect]are actively managing their policy and should be fully respected.- No
[protect]section → protection rules fall back to built-in defaults (safe baseline for users who only configure vault/guard). - Vault/Guard field-level merge — only fields explicitly written in
the config file override defaults. Other fields keep their defaults.
Documented in
docs/configuration.mdanddocs/configuration.zh.md.
list --jsonpreviously used hand-written string concatenation that omitted thebypassfield. Now usesjson.Marshal(entries)for correct serialization.
- Added
github.com/creack/ptydependency for pseudo-terminal tests. - 8 new tests in
internal/subcmd/subcmd_test.gocovering:isTerminal()(pipe & TTY),readLineWithTimeout()(timeout fires, normal input, disabled),emitNonTTYRejection()(bypass guidance, log creation),emitNonTTYRejectionTimeout()(timeout value in output). - All 7 packages pass
go vet ./... && go test ./....
--bypass=<host>/<platform>/<agent>/<task>flag for AI agents to explicitly confirm operations on protected paths from non-TTY contexts- Agent env
CMDGUARD_AGENT_MODE=1skips the interactive wait; rejection is instant (0 delay). Does NOT grant permission —--bypassis still required. Seeinternal/config/env.gofor the contract. bypassfield in audit log entries;cmdguard listdisplays[bypass:...]tags for traceability--bypassidentifier validation: 4 segments,[a-zA-Z0-9._-]+only, no angle brackets, no placeholder words (host,platform,agent,task,foo,todo,changeme, ...)
confirm_timeout(default 5s) andconfirm_double_timeout(default 10s per step) added to config. Set to 0 to disable timeout entirely.- stdin read timeout implemented via goroutine + channel +
time.After. Timeout fires → falls back to non-interactive rejection with bypass guidance. This solves the "pseudo-TTY no human" problem without platform-specific hacks.
rm/chmodon a non-existent target: cmdguard printsno such file or directoryand exits 1 immediately — no config lookup, no log entry, no confirm prompt.mvto a non-existent destination: cmdguard skips protection and executes the underlyingmvdirectly (creating a new file needs no backup). Existing destinations still trigger the normal protection flow.
- All user-facing messages extracted to
internal/msg/package, organized by category:guard.go,init.go,help.go,ops.go,errors.go - All code comments translated to English
- Example config renamed to
example/config.example.toml(follows the*.example.*convention used by.env.exampleand the broader ecosystem); contents fully English - TTY prompts improved:
press y to proceed, N or Enter to cancel, Ctrl+C to abort (timeout 5s) - Invalid bypass and non-TTY rejection guidance show the original command with formatted placeholders
internal/config/env.godefines all env vars cmdguard reads. Each constant has a doc comment explaining purpose, use case, and safety implications.os.Getenv("CMDGUARD_CONFIG_DIR")→os.Getenv(EnvConfigDir)- Config file read-only warning banner in
internal/config/config.go
- README.md in English (new), README.zh.md for Chinese
- docs/commands.md (English), docs/commands.zh.md (Chinese) — includes
--bypassreference with examples - docs/configuration.md (English), docs/configuration.zh.md (Chinese) —
adds
[guard]section, environment variables reference - docs/vault.md (English), docs/vault.zh.md (Chinese) — includes
bypassfield in the sample log entry
- All operations are now logged (previously
allowwas silent) - Default config:
~/Documents/archive/**→~/Documents/** - Added
/private/**to reject (macOS real path) - Added
~/Desktop/**to confirm - Removed all short flags (
-v,-h,-f) - Added
Makefile(make build/make installwith version injection)
- Added
--verboseflag (detailed execution info) - Fixed cmdguard flags leaking through to the underlying command
- Release builds no longer embed commit hash in version output
- Split docs:
docs/commands.md,docs/configuration.md,docs/vault.md - Completed doc coverage:
CMDGUARD_CONFIG_DIR,--check,--verbose,init --dry-run,undo --interactive,undo --dry-run
- Added
--checkflag (verify alias is active) - Added
--dry-runflag (rm/mv/chmod/init/undo/vault clean) - Added
--version/--helpfor guarded commands (shows underlying command info too) - Fixed
findRealCommandto skip the wrapper bin directory, preventing infinite recursion - Added GitHub Actions: CI (vet + test), Release (cross-compile + publish)
- Added
cmdguard initsubcommand (--forcebacks up old files as zip) - Added
cmdguard list --jsonoutput - Fixed pipeline mode (table and JSON input)
CMGGUARD_CONFIG_DIRrenamed toCMDGUARD_CONFIG_DIR- Improved docs: design rationale, human & agent protection
- Initial release
- Four protection levels: reject, confirm_double, confirm, warn
- Wrapped commands: rm, mv, chmod
- Automatic vault backup with undo support
- Audit log (JSON, one file per day)
- TOML config with glob path patterns