Post

The Compliance Fixer: From Finding to Reviewed Pull Request

The Compliance Fixer: From Finding to Reviewed Pull Request

Finding problems is the easy half. The compliance fixer turns findings into pull requests, and after a human merges, brings the Docker host to exactly the merged state without ever deleting data. It cleaned up years of “edit on the host” drift from one repo alone, using per-file safeguards instead of a blind git pull.

{% include_post 2026-10-02-hermes-agents-overview %} {% include_post 2026-09-29-hermes-agent %}

Purpose

The fixer addresses the last step that compliance checks cannot reach: applying corrections. A check flags a wrong timezone, missing logging, or a drifted config, and stops there. It then turns those findings into reviewed pull requests, so nothing lands on a host without human approval. It runs on the workstation on demand (triggered by the owner or Claude Code), never on Hermes, because it needs write access to repositories where Hermes has none.

Architecture

Every correction follows the same delivery rule: a PR first, then the host follows the merged repo. Patching hosts directly was rejected because unreviewed changes would keep drift alive; the host diverges again as soon as anyone edits a file without committing.

The fixer operates in four modes:

  • plan — reads the latest compliance inputs and prints every finding with the path it should take. Read-only, no side effects.
  • hygiene — builds a one-time cleanup PR for a compose directory that has local changes, untracked data files, or host edits mixed with tracked config.
  • apply tz — opens a timezone-fix PR for containers in a clean compose directory (one that hygiene already cleared).
  • sync — runs after a merge: the host fetches itself and updates only the files it is safe to touch.

The main entry point is fixer/fixer.py, running on the workstation with read-only compliance inputs staged over from Hermes. It delegates host-side work to host_agent.py sent over SSH stdin — nothing is installed on the Docker hosts, and the agent uses only Python stdlib. About 18 unit tests cover the modes, including real Git repositories for the sync path.

flowchart LR
    A["Compliance check<br/>(runs on Hermes)"] --> B["fixer plan<br/>(read-only finding review)"]
    B --> C{"Directory clean?"}
    C -->|yes| D["apply tz PR<br/>(or other fix PR)"]
    C -->|no| E["hygiene PR<br/>(one-time cleanup)"]
    D --> F["Human reviews + merges"]
    E --> F
    F --> G["Host fetches main<br/>(read-only deploy key)"]
    G --> H["sync<br/>(per-file safety update)"]
    H --> I{"Compose file changed?"}
    I -->|yes| J["docker compose up -d"]
    I -->|no| K["Done — no restart needed"]
    J --> L["Next compliance run confirms fix"]
    K --> L

The hygiene pass decides what is data and what is config by looking at the running containers: bind-mounted directories under the compose root are data, so they stop being tracked and are ignored. Files mounted individually inside those dirs stay tracked, as do entries listed explicitly in compliance.json. Secret-looking files (.env, keys, auth configs) are untracked with a warning to rotate them — they remain in Git history but stop polluting the working tree. Host-edited files outside data directories get committed, and any unpushed host commits carry over with git am. Gitleaks scans everything the PR adds or changes; a leak aborts the push.

One compose directory had about 30,000 data files and 13 GB of config tracked in Git — years of edits on the host that were committed but never pushed. After hygiene, it tracks four files. Timezone fixes work differently depending on the image: the agent probes TZ=&lt;zone&gt; date +%Z inside the container first. If the image has timezone data, only the TZ env var changes in the compose YAML. Otherwise, the host’s /usr/share/zoneinfo is mounted read-only as well. The edit is line-based so comments survive, and a parsed-YAML check ensures nothing but TZ (and the optional mount) changed.

Permissions and roles

The fixer runs on the workstation — not on Hermes. Hermes deliberately lacks write access to project repositories, and that boundary stays in place. Write access requires the owner’s Gitea token or a fixer config token. On the Docker hosts themselves, each has its own read-only deploy key generated locally that never leaves the host. The key can fetch from Gitea but every push is refused — so even if someone compromises a host, they cannot push anything back to the repos.

How it runs

The fixer does not run on a schedule. It triggers on demand: after the compliance report flags findings, the owner or Claude Code initiates a plan pass to review all deviations, then pushes PRs through hygiene or apply modes. After merging, sync is run per host — first a dry review that lists each file with its outcome, then --run to actually write changes and update Git index plus HEAD. If a compose file changed, --up triggers the container restart.

The sync step replaces what would have been a git pull — but pulling is not safe after a hygiene merge because the merged commit no longer tracks data directories. The flow works like this:

flowchart TD
    A["Hygiene PR merges<br/>(data dirs untracked)"] --> B{"Update method?"}
    B -->|"git pull"| C["Working tree refreshed to main"]
    C --> D["Data files deleted ❌<br/>they're gone from the repo"]
    B -->|"sync --run"| E["Fetch, diff per path"]
    E --> F{"Local match?"}
    F -->|conflict| G["Stop — no change<br/>host keeps local edit ✅"]
    F -->|overwrites backup first| H["Write new version<br/>git reset (index + HEAD only) ✅"]
    F -->|deleted upstream| I["File stays on disk<br/>index updated only ✅"]

Sync stops entirely on any conflict. Files the host edited where main also changed are skipped — not overwritten, not merged. Overwritten files back up to /root/fixer-backup/ first. A unit test demonstrates both paths: the plain pull that deletes data versus the per-file sync that keeps it.

How it relates to the other agents

The fixer is part of the Hermes agent series. It sits after the compliance checks run and before the host reaches a stable state — closing the gap between “we know something is wrong” and “the host actually matches the intended config.” The base system and its guardrails are covered in {% post_link 2026-09-29-hermes-agent %}the Hermes Agent overview, and the full agent lineup appears in {% post_link 2026-10-02-hermes-agents-overview %}the agents overview.

Notes

The fixer does not yet cover k3s manifests applied with kubectl, logging fixes beyond timezones, or non-Git directories (those need a repo created by hand before hygiene can run). ArgoCD apps were handled manually for the initial timezone pass. The tool is designed as a PR-first system — even sync writes are dry-reviewed before anything touches disk — because the lesson learned from years of drift was that direct edits, no matter how well-intentioned, keep reproducing the problem they try to solve.

This post is licensed under CC BY 4.0 by the author.