feat(devops): config-integrity-watchdog — git-backed config fingerprinting - #67
Conversation
…gerprinting Replaces mutable .sha256 sidecar with an append-only integrity log committed to the dotfiles git repo. A process without git commit credentials cannot silently forge a fingerprint entry. - seal.py: hash config + append to log + git commit - verify.py: compare current hash against latest seal; detect log tampering - restore.py: revert from git + re-seal; backs up tampered config - PLAN.md: project plan (Linear not available) - Tests: seal->verify, seal->tamper->verify, seal->tamper->restore->verify (29 passing) Closes the 19-day recurring config hijack pattern (Event #25+). Slack: https://mfc-nyc.slack.com/archives/C0BD8QBUSJF/p1782742870774319 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQKCc5mDedYAiCNyXnTezh
…x match Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQKCc5mDedYAiCNyXnTezh
Integrates config-integrity-watchdog into the Hermes CLI so users can seal, verify, and restore config integrity without remembering script paths. Calls core logic shared with the standalone scripts. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQKCc5mDedYAiCNyXnTezh
…caveat - _import_core() now tries ~/.hermes/skills first, then repo-relative skills/ as fallback for pre-sync usage; prints clear error + exits 1 if skill not found in either location - Move PLAN.md to .plans/config-integrity-watchdog.md - Append git-HEAD caveat to restore.py module docstring Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQKCc5mDedYAiCNyXnTezh
🔎 Lint report:
|
| Rule | Count |
|---|---|
unresolved-import |
3 |
unresolved-attribute |
1 |
First entries
hermes_cli/config_integrity_cli.py:47: [unresolved-attribute] unresolved-attribute: Object of type `Action` has no attribute `add_parser`
hermes_cli/config_integrity_cli.py:78: [unresolved-import] unresolved-import: Cannot resolve imported module `config_integrity`
tests/skills/test_config_integrity_watchdog.py:19: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_config_integrity_cli.py:24: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
✅ Fixed issues: none
Unchanged: 4576 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9c3bdb0dd8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
|
|
||
| def _default_config_path() -> Path: | ||
| return Path(os.environ.get("HERMES_CONFIG", "~/.hermes/config.yaml")).expanduser() |
There was a problem hiding this comment.
Use the active Hermes home for default config paths
In profile runs such as hermes -p work config seal, _apply_profile_override() sets HERMES_HOME, not HERMES_CONFIG, so this default still hashes/restores ~/.hermes/config.yaml from the default profile. That can seal or overwrite the wrong profile's config; default to get_hermes_home()/config.yaml (and mirror this in the scripts) unless HERMES_CONFIG is explicitly set.
Useful? React with 👍 / 👎.
| log_path = _log_path(dotfiles_dir) | ||
| canonical_config = _canonical_config_path(dotfiles_dir) | ||
|
|
||
| baseline = load_baseline_entry(log_path) |
There was a problem hiding this comment.
Refuse restore when the integrity log is dirty
When verify returns 2 for uncommitted log changes, restore can still be run and it loads the last uncommitted seal entry here. If a tampering process appends a seal for the tampered config, restore sees current_hash == baseline_hash, does a no-op, and exits 0; add the same log_has_uncommitted_changes guard before trusting the baseline.
Useful? React with 👍 / 👎.
| subprocess.run( | ||
| ["git", "add", str(log_path.relative_to(dotfiles_dir))], | ||
| cwd=dotfiles_dir, check=True, capture_output=True, |
There was a problem hiding this comment.
Stage the config snapshot when sealing
The documented flow says to run seal after intentional config changes, but this helper only stages config_integrity.jsonl. If hermes/config.yaml changed in the dotfiles repo (or the protected config is copied there) but was not committed separately, the committed baseline hash advances while HEAD:hermes/config.yaml remains old, so restore() checks out stale content and the next verify still fails.
Useful? React with 👍 / 👎.
| new_hash = hash_file(config_path) | ||
| if new_hash != baseline_hash: | ||
| print( | ||
| f"WARNING: Restored hash {new_hash[:16] if new_hash else 'None'} " | ||
| f"doesn't match baseline {baseline_hash[:16]}", | ||
| file=sys.stderr, | ||
| ) |
There was a problem hiding this comment.
Return failure when restore misses the sealed hash
If git checkout succeeds but produces content whose hash does not match the latest seal (for example because HEAD has an older config than the log), this branch only warns and then appends a restore entry and returns 0. Cron/CLI callers will treat the restore as successful even though verify remains failing, so this mismatch should stop with a non-zero status.
Useful? React with 👍 / 👎.
| related_skills: [ugw-health-check] | ||
| --- | ||
|
|
||
| ## When to Use |
There was a problem hiding this comment.
Add the required skill title and intro
The root AGENTS.md skill standards require the body order to start with # <Skill> Skill and a 2–3 sentence intro before ## When to Use; this new SKILL.md begins directly with ## When to Use, so it violates the documented hardline skill-review checklist despite valid frontmatter.
Useful? React with 👍 / 👎.
| subprocess.run( | ||
| ["git", "commit", "-m", message], | ||
| cwd=dotfiles_dir, check=True, capture_output=True, |
There was a problem hiding this comment.
Limit integrity commits to owned paths
When a user already has unrelated files staged in the dotfiles repo, this pathless git commit commits the entire index under the integrity message after staging the log. Limit the diff/commit to the integrity paths (and the config snapshot, if added) or abort when unrelated staged changes already exist; the restore path uses the same pathless commit pattern too.
Useful? React with 👍 / 👎.
| candidates = [ | ||
| Path.home() / ".hermes" / "skills" / "devops" / "config-integrity-watchdog", | ||
| Path(__file__).parent.parent / "skills" / "devops" / "config-integrity-watchdog", | ||
| ] | ||
| for skills_root in candidates: | ||
| if skills_root.exists(): | ||
| if str(skills_root) not in sys.path: | ||
| sys.path.insert(0, str(skills_root)) | ||
| try: | ||
| import config_integrity # noqa: PLC0415 | ||
| return config_integrity |
There was a problem hiding this comment.
Load bundled integrity code before user copies
The CLI trust boundary is weakened because hermes config verify/restore imports config_integrity.py from the mutable user skill directory before the repo-bundled copy. In the threat model described by this feature, a process that can edit config.yaml can also replace this user skill and make verification report OK or run arbitrary code, so the built-in command should prefer trusted bundled code rather than ~/.hermes/skills.
Useful? React with 👍 / 👎.
| print(f"Sealed: {config_hash[:16]}...") | ||
| print(f" Log: {log_path}") | ||
|
|
||
| return 0 |
There was a problem hiding this comment.
Fail seal when a git commit cannot be anchored
When dotfiles_dir is a git repo but git commit fails, such as missing git identity or a rejecting hook, this still returns success after writing an uncommitted seal entry. The very next verify then returns 2 because the log is dirty, so callers believe they sealed a usable baseline when they actually created an unusable one.
Useful? React with 👍 / 👎.
|
|
||
|
|
||
| def _default_dotfiles_dir() -> Path: | ||
| return Path(os.environ.get("HERMES_DOTFILES_DIR", "~/Dev/dotfiles")).expanduser() |
There was a problem hiding this comment.
Configure dotfiles path through Hermes config
This path setting is only read from an env var and otherwise defaults to the contributor-specific ~/Dev/dotfiles; users running the new built-in hermes config seal without that private layout get a mutable fallback under a newly created directory instead of the advertised git-backed anchor. Per the repo config policy, non-secret paths should live in config.yaml or skill metadata rather than relying on ad-hoc env defaults.
Useful? React with 👍 / 👎.
| ```bash | ||
| # Seal the current config as canonical | ||
| python3 ~/.hermes/skills/devops/config-integrity-watchdog/scripts/seal.py | ||
|
|
There was a problem hiding this comment.
Use Hermes tool names in the skill run instructions
The root AGENTS.md skill standards require SKILL.md prose to present native Hermes tools or expected MCP servers as the interaction surface, but this How to Run section leads with raw python3 script invocations. Rephrase this around the new hermes config seal/verify/restore commands or the relevant Hermes tools so the skill meets the documented hardline checklist.
Useful? React with 👍 / 👎.
… mark arc complete (#76) Session close-out audit found the Outcome section only mentioned PR #67. PR #69 explicitly self-describes as "follow-up to #67" (unsigned-commit git fallback) and PR #68 (AGENTS.md docs) is also a direct follow-up; neither was recorded. Also clarifies that #71/#72 (cron ticker heartbeat/stall fix) are an unrelated arc shipped the same day, not part of this project. Claude-Session: https://claude.ai/code/session_01PQKCc5mDedYAiCNyXnTezh Co-authored-by: Claude <noreply@anthropic.com>
…writes (#82) save_config()/restore_config() already keep the local .sha256 sidecar in sync (PR #57), but the *external* git-backed baseline used by `hermes config verify` (PR #67) was only ever updated by an explicit `hermes config seal`. Any authorized write through save_config() — model scanner, /model command, platform setup flows via write_platform_config_field() — desynced that baseline, so the Config Integrity Watchdog cron job flagged the legitimate change as tampering: "Hermes Config Integrity Failure! The configuration hash does not match the sealed baseline." This is the same scanner/watchdog TOCTOU conflict documented in docs/plans/2026-07-02-scanner-watchdog-conflict-resolution.md, just recurring one layer over in the newer git-backed mechanism. _write_config_to_disk() now also calls the config-integrity-watchdog skill's seal() (quietly) whenever $HERMES_DOTFILES_DIR is configured, keeping the git-backed log current on every authorized write. Extracted _find_core_module() (returns None instead of exiting) so this can opportunistically no-op on machines without the watchdog set up. restore_quick_snapshot() in backup.py gets the same treatment for config.yaml restores from a quick snapshot. Co-authored-by: Test <test@test.com>
Summary
Replaces the mutable
.sha256sidecar file with a git-backed, append-only integrity log stored in the dotfiles repository. The Config Integrity Watchdog has triggered 25+ times in 19 days because any process that can writeconfig.yamlcan also overwrite the sidecar — silently masking the tampering. A git commit cannot be silently forged by a process without git credentials.What ships
skills/devops/config-integrity-watchdog/— new skill (auto-synced by skills_sync):scripts/seal.py— hashes config, appends toconfig_integrity.jsonl, commits to dotfiles gitscripts/verify.py— compares current hash to latest seal; exit 1 if tampered, exit 2 if log itself modifiedscripts/restore.py—git checkout HEADto revert config, backs up tampered file, re-sealsconfig_integrity.py— shared core module imported by both scripts and CLIhermes_cli/config_integrity_cli.py+ wiring inconfig.py/main.py— new CLI subcommands:.plans/config-integrity-watchdog.md— project tracking doc (moved from PLAN.md)Configuration
HERMES_CONFIG~/.hermes/config.yamlHERMES_DOTFILES_DIR~/Dev/dotfilesTests
tests/skills/test_config_integrity_watchdog.py)tests/hermes_cli/test_config_integrity_cli.py)Test plan
hermes config seal— should print ✅ and exit 0hermes config verify— should print OK and exit 0hermes config verify— should print 🚨 and exit 1hermes config restore— should revert config, print ✅, exit 0hermes config verify— should pass againSlack thread
https://mfc-nyc.slack.com/archives/C0BD8QBUSJF/p1782742870774319
Generated by Claude Code