Add wrapper mode, update README, version bump 0.3.0
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 2m7s

This commit is contained in:
2025-12-30 17:27:15 +00:00
parent 7c391b8dbc
commit 20a0dca080
3 changed files with 148 additions and 10 deletions

View File

@@ -30,6 +30,31 @@ A single confirmation prompt at the end of a restore (default: **No**).
### Dry-run mode ### Dry-run mode
Preview restore operations without prompting or applying changes. Preview restore operations without prompting or applying changes.
### Wrapper mode (automatic snapshots)
`chguard` can also run as a wrapper around ownership and permission commands.
In this mode, `chguard` automatically saves a snapshot before the command runs, so the user can easily restore the previous state if needed.
#### Supported commands
Wrapper mode is intentionally limited to commands that modify filesystem metadata only:
* `chown`
* `chmod`
* `chgrp`
Other commands are rejected to avoid giving a _false sense of protection_.
#### Automatic snapshot names
Snapshots created in wrapper mode are named automatically, for example:
```
auto-20251230-161301
```
Auto-generated snapshots are visually distinguished in the output so they are easy to identify.
### Scope control ### Scope control
Restore: Restore:
* both ownership and permissions (default) * both ownership and permissions (default)
@@ -55,7 +80,6 @@ Restore:
It only concerns itself with **ownership** and **permissions**. It only concerns itself with **ownership** and **permissions**.
## Installation ## Installation
### From GuardUtils package repo ### From GuardUtils package repo
@@ -179,6 +203,16 @@ chguard --restore app-baseline --permissions
chguard --restore app-baseline --owner chguard --restore app-baseline --owner
``` ```
### Wrapper mode
Use `--` to separate `chguard` arguments from the wrapped command:
```
chguard -- chown user:group file
chguard -- chmod 755 file
chguard -- chgrp staff file
```
## Privilege model ## Privilege model
`chguard` never escalates privileges automatically `chguard` never escalates privileges automatically

View File

@@ -8,6 +8,7 @@ import sys
import stat import stat
import pwd import pwd
import grp import grp
import subprocess
from collections import Counter, defaultdict from collections import Counter, defaultdict
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
@@ -37,7 +38,6 @@ def get_version():
def _uid_to_name(uid: int) -> str: def _uid_to_name(uid: int) -> str:
"""Return username for uid, or uid as string if unknown."""
try: try:
return pwd.getpwuid(uid).pw_name return pwd.getpwuid(uid).pw_name
except KeyError: except KeyError:
@@ -45,7 +45,6 @@ def _uid_to_name(uid: int) -> str:
def _gid_to_name(gid: int) -> str: def _gid_to_name(gid: int) -> str:
"""Return group name for gid, or gid as string if unknown."""
try: try:
return grp.getgrgid(gid).gr_name return grp.getgrgid(gid).gr_name
except KeyError: except KeyError:
@@ -53,12 +52,10 @@ def _gid_to_name(gid: int) -> str:
def _format_owner(uid: int, gid: int) -> str: def _format_owner(uid: int, gid: int) -> str:
"""Format uid/gid as username:group."""
return f"{_uid_to_name(uid)}:{_gid_to_name(gid)}" return f"{_uid_to_name(uid)}:{_gid_to_name(gid)}"
def _mode_to_rwx(mode: int) -> str: def _mode_to_rwx(mode: int) -> str:
"""Convert numeric mode to rwx-style permissions."""
bits = ( bits = (
stat.S_IRUSR, stat.S_IRUSR,
stat.S_IWUSR, stat.S_IWUSR,
@@ -88,7 +85,6 @@ def _mode_to_rwx(mode: int) -> str:
def _is_root() -> bool: def _is_root() -> bool:
"""Return True if running as root."""
return os.geteuid() == 0 return os.geteuid() == 0
@@ -105,6 +101,17 @@ def complete_state_names(prefix, parsed_args, **kwargs):
return [] return []
def _extract_paths_from_command(cmd: list[str]) -> list[Path]:
paths = []
for arg in cmd:
if arg.startswith("-"):
continue
p = Path(arg)
if p.exists():
paths.append(p.resolve())
return paths
def main() -> None: def main() -> None:
""" """
Entry point for the CLI. Entry point for the CLI.
@@ -116,12 +123,33 @@ def main() -> None:
- Symlinks are skipped during scanning - Symlinks are skipped during scanning
""" """
wrapper_cmd = None
if "--" in sys.argv:
idx = sys.argv.index("--")
wrapper_cmd = sys.argv[idx + 1 :]
sys.argv = sys.argv[:idx]
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="chguard", prog="chguard",
description="Snapshot and restore filesystem ownership and permissions.", description="Snapshot and restore filesystem ownership and permissions.",
) )
actions = parser.add_mutually_exclusive_group(required=True) parser = argparse.ArgumentParser(
prog="chguard",
description="Snapshot and restore filesystem ownership and permissions.",
epilog=(
"Wrapper mode:\n"
" chguard -- chown [OPTIONS] PATH...\n"
" chguard -- chmod [OPTIONS] PATH...\n"
" chguard -- chgrp [OPTIONS] PATH...\n\n"
"In wrapper mode, chguard automatically saves a snapshot of ownership\n"
"and permissions for the affected paths before running the command.\n"
"Only chown, chmod, and chgrp are supported."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
actions = parser.add_mutually_exclusive_group(required=wrapper_cmd is None)
parser.add_argument( parser.add_argument(
"--version", "--version",
@@ -216,11 +244,84 @@ def main() -> None:
argcomplete.autocomplete(parser) argcomplete.autocomplete(parser)
args = parser.parse_args() args = parser.parse_args()
if wrapper_cmd is not None:
if not wrapper_cmd:
raise SystemExit("No command provided after '--'")
cmd = Path(wrapper_cmd[0]).name
if cmd not in ("chown", "chmod", "chgrp"):
raise SystemExit(
"Wrapper mode only supports chown, chmod, and chgrp"
)
console = Console() console = Console()
conn = connect(Path(args.db).expanduser().resolve() if args.db else None) conn = connect(Path(args.db).expanduser().resolve() if args.db else None)
init_db(conn) init_db(conn)
if wrapper_cmd:
paths = _extract_paths_from_command(wrapper_cmd)
if paths:
auto_name = f"auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
with conn:
root_path = str(Path(paths[0]).resolve())
state_id = create_state(
conn, auto_name, root_path, os.getuid(), commit=False
)
for path in paths:
if path.is_dir():
for entry in scan_tree(path):
if entry.uid == 0 and not _is_root():
raise SystemExit(
"This command affects root-owned files.\n"
"Please re-run with sudo."
)
conn.execute(
"""
INSERT INTO entries (state_id, path, type, mode, uid, gid)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
state_id,
entry.path,
entry.type,
entry.mode,
entry.uid,
entry.gid,
),
)
else:
st = path.lstat()
if st.st_uid == 0 and not _is_root():
raise SystemExit(
"This command affects root-owned files.\n"
"Please re-run with sudo."
)
conn.execute(
"""
INSERT INTO entries (state_id, path, type, mode, uid, gid)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
state_id,
str(path),
"file",
stat.S_IMODE(st.st_mode),
st.st_uid,
st.st_gid,
),
)
console.print(
f"Saved pre-command snapshot: [cyan]{auto_name}[/cyan]"
)
proc = subprocess.run(wrapper_cmd)
sys.exit(proc.returncode)
if args.list: if args.list:
rows = conn.execute( rows = conn.execute(
"SELECT name, root_path, created_at FROM states ORDER BY created_at DESC" "SELECT name, root_path, created_at FROM states ORDER BY created_at DESC"
@@ -233,7 +334,10 @@ def main() -> None:
for name, root, created in rows: for name, root, created in rows:
dt = datetime.fromisoformat(created) dt = datetime.fromisoformat(created)
ts = dt.strftime("%Y-%m-%d %H:%M:%S %z") ts = dt.strftime("%Y-%m-%d %H:%M:%S %z")
console.print(f"{name}\t{root}\t[dim]{ts}[/dim]") if name.startswith("auto-"):
console.print(f"[cyan]{name}[/cyan]\t{root}\t{ts}")
else:
console.print(f"{name}\t{root}\t{ts}")
return return
if args.delete: if args.delete:
@@ -346,7 +450,7 @@ def main() -> None:
] = f"{_format_owner(bu, bg)}{_format_owner(au, ag)}" ] = f"{_format_owner(bu, bg)}{_format_owner(au, ag)}"
counts["owner"] += 1 counts["owner"] += 1
if au != current_uid: if ch.path.stat().st_uid != current_uid:
needs_root = True needs_root = True
elif ch.kind == "mode" and restore_permissions: elif ch.kind == "mode" and restore_permissions:

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "chguard" name = "chguard"
version = "0.2.2" version = "0.3.0"
description = "Safety-first tool to snapshot and restore filesystem ownership and permissions." description = "Safety-first tool to snapshot and restore filesystem ownership and permissions."
authors = ["Marco D'Aleo <marco@marcodaleo.com>"] authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"