Skip to content

Commit d70c0e5

Browse files
committed
feat(cli): add cloak status + cloak restore commands (v0.8.0)
- cloak status: read-only session diagnostics (session state, manifest, file delta, vault stats, tag residue, backups, audit events). Supports --json and --audit-lines N. - cloak restore: two restore modes — vault-based (default) replaces tags with secrets + R4/R5 verification; backup-based (--from-backup) copies pre-redaction files with --force for execution, dry-run preview without. - restore_from_backup() in dirpack.py with dry-run support - _read_audit_tail(), list_backups() helpers in hooks.py - New audit events: restore_vault, restore_backup - MCP server and FastAPI server versions now use __version__ dynamically - 37 new tests across 5 test classes (153 hook tests total)
1 parent de81124 commit d70c0e5

10 files changed

Lines changed: 904 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.8.0] - 2026-02-22
11+
12+
### Added
13+
- **`cloak status`**: read-only session diagnostics — session state, manifest summary,
14+
file delta, vault stats, tag residue, available backups, legacy backup warnings, recent
15+
audit events. Supports `--json` and `--audit-lines N`.
16+
- **`cloak restore`**: two restore modes:
17+
- **Vault-based** (default): replaces tags with secrets, runs R4 verification + R5 delta,
18+
cleans session state. Works even without session state if vault has data.
19+
- **Backup-based** (`--from-backup`): copies pre-redaction files from external backup. Requires
20+
`--force` for execution. Without `--force`, shows dry-run preview. Lists available backups
21+
when no `--backup-id` specified.
22+
- `restore_from_backup()` in dirpack.py: file-copy restore with dry-run support
23+
- `_read_audit_tail()`, `list_backups()` helpers in hooks.py
24+
- New audit events: `restore_vault`, `restore_backup`
25+
- ~37 new tests across 5 test classes
26+
1027
## [0.7.0] - 2026-02-22
1128

1229
### Security
@@ -222,7 +239,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
222239
- HMAC-based pseudonymization
223240
- JSONL audit logging
224241

225-
[Unreleased]: https://github.com/ovitrac/CloakMCP/compare/v0.7.0...HEAD
242+
[Unreleased]: https://github.com/ovitrac/CloakMCP/compare/v0.8.0...HEAD
243+
[0.8.0]: https://github.com/ovitrac/CloakMCP/compare/v0.7.0...v0.8.0
226244
[0.7.0]: https://github.com/ovitrac/CloakMCP/compare/v0.6.3...v0.7.0
227245
[0.6.3]: https://github.com/ovitrac/CloakMCP/compare/v0.6.0...v0.6.3
228246
[0.6.0]: https://github.com/ovitrac/CloakMCP/compare/v0.5.1...v0.6.0

README.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
1313
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
1414
[![PyPI](https://img.shields.io/pypi/v/cloakmcp.svg)](https://pypi.org/project/cloakmcp/)
15-
[![Version](https://img.shields.io/badge/version-0.7.0-orange.svg)](https://github.com/ovitrac/CloakMCP/releases)
16-
[![Tests](https://img.shields.io/badge/tests-240%20passing-brightgreen.svg)](./tests)
15+
[![Version](https://img.shields.io/badge/version-0.8.0-orange.svg)](https://github.com/ovitrac/CloakMCP/releases)
16+
[![Tests](https://img.shields.io/badge/tests-277%20passing-brightgreen.svg)](./tests)
1717
[![MCP](https://img.shields.io/badge/MCP-6%20tools-blueviolet.svg)](#mcp-tool-server--6-tools)
1818
[![DeepWiki](https://img.shields.io/badge/Docs-DeepWiki-purple.svg)](https://deepwiki.com/ovitrac/CloakMCP)
1919
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
@@ -206,6 +206,8 @@ CloakMCP exposes tools via the **Model Context Protocol** (JSON-RPC 2.0 over std
206206
| `cloak sanitize-stdin --policy POL` | Sanitize text from stdin to stdout (pipe helper) |
207207
| `cloak repack --dir DIR --policy POL` | Incremental re-pack: scan new/changed files only |
208208
| `cloak verify --dir DIR` | Post-unpack verification: scan for residual tags |
209+
| `cloak status --dir DIR` | Session diagnostics: state, manifest, delta, vault, tags, backups, audit |
210+
| `cloak restore --dir DIR` | Restore secrets from vault (default) or `--from-backup --force` |
209211
| `cloak hook <event>` | Hook handler for Claude Code integration (session-start, session-end, guard-write, guard-read, prompt-guard, safety-guard, audit-log) |
210212
| `cloak-mcp-server` | MCP tool server (JSON-RPC 2.0 over stdio, any MCP client) |
211213

@@ -387,6 +389,8 @@ sequenceDiagram
387389
└── <timestamp>/ # Timestamped snapshot (outside project tree)
388390
```
389391

392+
Backups can be restored with `cloak restore --from-backup --backup-id <timestamp> --force`.
393+
390394
- **Slug**: 16-character SHA-256 prefix of project's absolute path
391395
- **Encryption**: AES-128 via `cryptography.Fernet`
392396
- **Permissions**: 0600 (owner read/write only)
@@ -630,14 +634,14 @@ All endpoints require Bearer token authentication. Server binds to `127.0.0.1` o
630634
```bash
631635
pip install -e ".[test]"
632636

633-
# Run all tests (240 passing)
637+
# Run all tests (277 passing)
634638
pytest
635639

636640
# Run with coverage
637641
pytest --cov=cloakmcp --cov-report=term
638642
```
639643

640-
**Test suite**: 240+ tests across 7 test files covering unit tests, integration tests, API tests, hook tests, MCP server tests, and enterprise policy tests.
644+
**Test suite**: 277 tests across 7 test files covering unit tests, integration tests, API tests, hook tests, MCP server tests, and enterprise policy tests.
641645

642646
---
643647

@@ -660,7 +664,7 @@ CloakMCP/
660664
│ ├── server.py # FastAPI REST server (localhost)
661665
│ ├── storage.py # Vault encryption (Fernet AES-128)
662666
│ └── utils.py # Utilities (hashing, encoding)
663-
├── tests/ # Test suite (240+ tests, 7 files)
667+
├── tests/ # Test suite (277 tests, 7 files)
664668
│ ├── test_comprehensive.py # Full feature tests
665669
│ ├── test_api.py # API endpoint tests
666670
│ ├── test_filepack.py # Pack/unpack round-trip tests
@@ -700,7 +704,7 @@ CloakMCP/
700704
├── .mcp.json # MCP server discovery for Claude Code
701705
├── .vscode/ # VS Code integration (tasks, keybindings)
702706
├── .mcpignore # Pack/unpack exclusion patterns
703-
├── pyproject.toml # Package metadata (v0.7.0)
707+
├── pyproject.toml # Package metadata (v0.8.0)
704708
├── pytest.ini # Pytest configuration
705709
├── CHANGELOG.md # Full release history
706710
├── SECURITY.md # Security policy and disclosure
@@ -757,7 +761,7 @@ Commit convention: `type(scope): description` (e.g., `feat(hooks): add guard-wri
757761

758762
See **[`CHANGELOG.md`](CHANGELOG.md)** for the full release history.
759763

760-
**Latest**: v0.7.0 — Backup exfiltration fix (backups moved outside project tree), guard-read hook (hardened profile), legacy backup warnings
764+
**Latest**: v0.8.0 — `cloak status` diagnostics, `cloak restore` (vault-based + backup-based), ~37 new tests
761765

762766
---
763767

cloakmcp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '0.7.0'
1+
__version__ = '0.8.0'

cloakmcp/cli.py

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import argparse
33
import os
44
import sys
5-
from typing import Tuple
5+
from typing import Any, Dict, List, Tuple
66

77
from .normalizer import normalize
88
from .policy import Policy
@@ -80,6 +80,98 @@ def sanitize_text(text: str, policy: Policy, dry_run: bool = False) -> Tuple[str
8080
)
8181
return ("".join(out) if not dry_run else text, blocked)
8282

83+
def _print_status(status: Dict[str, Any]) -> None:
84+
"""Print human-readable status report to stderr."""
85+
print("=" * 50, file=sys.stderr)
86+
print("CloakMCP Status", file=sys.stderr)
87+
print("=" * 50, file=sys.stderr)
88+
89+
# Session
90+
active = status.get("session_active", False)
91+
print(f"\nSession: {'ACTIVE' if active else 'INACTIVE'}", file=sys.stderr)
92+
if active and status.get("session"):
93+
session = status["session"]
94+
print(f" Policy: {session.get('policy', 'N/A')}", file=sys.stderr)
95+
print(f" Prefix: {session.get('prefix', 'N/A')}", file=sys.stderr)
96+
if session.get("backup_path"):
97+
print(f" Backup: {session['backup_path']}", file=sys.stderr)
98+
99+
# Manifest
100+
manifest = status.get("manifest")
101+
if manifest:
102+
print(f"\nManifest: {manifest.get('total_files', 0)} files "
103+
f"(ts: {manifest.get('timestamp', 'N/A')})", file=sys.stderr)
104+
else:
105+
print("\nManifest: none", file=sys.stderr)
106+
107+
# Delta
108+
delta = status.get("delta")
109+
if delta:
110+
new_files = delta.get("new_files", [])
111+
deleted_files = delta.get("deleted_files", [])
112+
changed_files = delta.get("changed_files", [])
113+
unchanged = delta.get("unchanged_count", 0)
114+
print(f"\nDelta: {len(new_files)} new, {len(deleted_files)} deleted, "
115+
f"{len(changed_files)} changed, {unchanged} unchanged", file=sys.stderr)
116+
for label, files in [("New", new_files), ("Deleted", deleted_files),
117+
("Changed", changed_files)]:
118+
for f in files[:10]:
119+
print(f" [{label}] {f}", file=sys.stderr)
120+
if len(files) > 10:
121+
print(f" ... and {len(files) - 10} more", file=sys.stderr)
122+
123+
# Vault
124+
vault = status.get("vault")
125+
if vault:
126+
print(f"\nVault: {vault.get('total_secrets', 0)} secrets, "
127+
f"{vault.get('unique_tags', 0)} unique tags", file=sys.stderr)
128+
print(f" Path: {vault.get('vault_path', 'N/A')}", file=sys.stderr)
129+
else:
130+
print("\nVault: unavailable", file=sys.stderr)
131+
132+
# Tag residue
133+
residue = status.get("tag_residue")
134+
if residue:
135+
print(f"\nTag residue: {residue.get('tags_found', 0)} found, "
136+
f"{residue.get('tags_resolved', 0)} resolved, "
137+
f"{residue.get('tags_unresolvable', 0)} unresolvable", file=sys.stderr)
138+
unresolvable_files = residue.get("unresolvable_files", [])
139+
for rel_path, count in unresolvable_files[:5]:
140+
print(f" {rel_path}: {count} tag(s)", file=sys.stderr)
141+
if len(unresolvable_files) > 5:
142+
print(f" ... and {len(unresolvable_files) - 5} more files",
143+
file=sys.stderr)
144+
145+
# Backups
146+
backups = status.get("backups")
147+
if backups:
148+
print(f"\nBackups: {len(backups)} available", file=sys.stderr)
149+
for b in backups[:5]:
150+
print(f" {b['timestamp']} ({b['file_count']} files)", file=sys.stderr)
151+
if len(backups) > 5:
152+
print(f" ... and {len(backups) - 5} more", file=sys.stderr)
153+
else:
154+
print("\nBackups: none", file=sys.stderr)
155+
156+
# Legacy warning
157+
legacy = status.get("legacy_warning")
158+
if legacy:
159+
print(f"\n{legacy}", file=sys.stderr)
160+
161+
# Recent audit
162+
audit = status.get("recent_audit")
163+
if audit:
164+
print(f"\nRecent audit ({len(audit)} events):", file=sys.stderr)
165+
for evt in audit:
166+
ts = evt.get("ts", "?")
167+
event_type = evt.get("event", "?")
168+
print(f" [{ts}] {event_type}", file=sys.stderr)
169+
else:
170+
print("\nAudit: no events", file=sys.stderr)
171+
172+
print("", file=sys.stderr)
173+
174+
83175
def main() -> None:
84176
p = argparse.ArgumentParser(prog="cloak", description="Micro-Cleanse Preprocessor (local secret-removal)")
85177
sub = p.add_subparsers(dest="cmd", required=True)
@@ -183,6 +275,24 @@ def main() -> None:
183275
# ── scripts-path ─────────────────────────────────────────────
184276
sub.add_parser("scripts-path", help="Print path to bundled installer scripts")
185277

278+
# ── v0.8.0: session status ─────────────────────────────────────
279+
s_status = sub.add_parser("status", help="Show session status and diagnostics")
280+
s_status.add_argument("--dir", default=".", help="Project root directory")
281+
s_status.add_argument("--json", action="store_true", dest="json_output",
282+
help="Output as JSON (machine-readable)")
283+
s_status.add_argument("--audit-lines", type=int, default=10,
284+
help="Number of recent audit events to show (default: 10)")
285+
286+
# ── v0.8.0: restore ───────────────────────────────────────────
287+
s_restore = sub.add_parser("restore", help="Restore secrets (vault-based or from backup)")
288+
s_restore.add_argument("--dir", default=".", help="Project root directory")
289+
s_restore.add_argument("--from-backup", action="store_true",
290+
help="Restore from external backup instead of vault")
291+
s_restore.add_argument("--force", action="store_true",
292+
help="Execute destructive backup restore (required with --from-backup)")
293+
s_restore.add_argument("--backup-id", default=None,
294+
help="Timestamp of specific backup to restore from")
295+
186296
# ── Dispatch ────────────────────────────────────────────────
187297

188298
args = p.parse_args()
@@ -377,6 +487,25 @@ def main() -> None:
377487
handle_recover(project_dir=args.dir)
378488
return
379489

490+
if args.cmd == "status":
491+
from .hooks import handle_status
492+
_validate_dir_path(args.dir, "directory")
493+
result = handle_status(project_dir=args.dir, json_output=args.json_output,
494+
audit_lines=args.audit_lines)
495+
if args.json_output:
496+
import json as _json
497+
print(_json.dumps(result, indent=2, default=str))
498+
else:
499+
_print_status(result)
500+
return
501+
502+
if args.cmd == "restore":
503+
from .hooks import handle_restore
504+
_validate_dir_path(args.dir, "directory")
505+
handle_restore(project_dir=args.dir, from_backup=args.from_backup,
506+
force=args.force, backup_id=args.backup_id)
507+
return
508+
380509
if args.cmd == "verify":
381510
from .dirpack import verify_unpack
382511
_validate_dir_path(args.dir, "directory")

cloakmcp/dirpack.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,47 @@ def cleanup_backup(backup_path: str) -> None:
8686
shutil.rmtree(backup_path, ignore_errors=True)
8787

8888

89+
def restore_from_backup(
90+
backup_path: str, project_dir: str, dry_run: bool = False
91+
) -> Tuple[int, int]:
92+
"""Copy files from external backup back into project directory.
93+
94+
DESTRUCTIVE when dry_run=False: overwrites current files with backup copies.
95+
96+
Args:
97+
backup_path: Path to the timestamped backup directory
98+
project_dir: Project root directory to restore into
99+
dry_run: If True, count files only without copying
100+
101+
Returns:
102+
(restored_count, skipped_count)
103+
"""
104+
if not os.path.isdir(backup_path):
105+
return (0, 0)
106+
107+
restored = 0
108+
skipped = 0
109+
110+
for dirpath, _dirnames, filenames in os.walk(backup_path):
111+
for name in filenames:
112+
src = os.path.join(dirpath, name)
113+
rel_path = os.path.relpath(src, backup_path)
114+
target = os.path.join(project_dir, rel_path)
115+
116+
if dry_run:
117+
restored += 1
118+
continue
119+
120+
try:
121+
os.makedirs(os.path.dirname(target), exist_ok=True)
122+
shutil.copy2(src, target)
123+
restored += 1
124+
except OSError:
125+
skipped += 1
126+
127+
return (restored, skipped)
128+
129+
89130
def warn_legacy_backups(root: str) -> Optional[str]:
90131
"""Return warning string if legacy .cloak-backups/ exists in project tree."""
91132
legacy_path = os.path.join(root, BACKUP_DIR)

0 commit comments

Comments
 (0)