On this page
Pre-launch diagnostics: symlinks, tokens, disk usage, permission drift, and deployed hook-script drift checked against the canonical guardrail model.
#claudewheel.health
#claudewheel.health
Pre-launch diagnostics: symlinks, tokens, disk usage, and permission/hook drift against the canonical guardrail model.
#HealthResult
Health check result with ok status, label, and detail message.
#check_tmpfs_quota
def check_tmpfs_quota() -> HealthResultCheck /tmp usage percentage via df.
#_tmp_claude_dir
def _tmp_claude_dir() -> PathReturn the per-user Claude scratch dir under /tmp.
#_real_disk_usage
def _real_disk_usage(root: Path) -> intSum the real tmpfs block usage of regular files under root.
Correctness requirements this satisfies:
- Never follows symlinks. os.walk(followlinks=False) does not descend into
symlinked directories, and lstat + S_ISREG skips symlinks to files. So symlink targets living outside /tmp (Claude session dirs link into home and project dirs) are never counted -- they consume zero /tmp space.
- Counts REAL disk usage (st_blocks * 512), not apparent st_size. tmpfs
charges by allocated blocks; apparent size overcounts sparse files.
#check_tmp_claude_size
def check_tmp_claude_size() -> HealthResultCheck real tmpfs usage of /tmp/claude-$UID/ (excludes symlink targets).
#_discover_profiles
def _discover_profiles(ws: 'Workspace', tokens: dict[str, Any] | None=None) -> list[Profile]Enumerate profiles via the workspace's ProfileStore.
Thin adapter over the shared :meth:ProfileStore.discover helper in the "raise" mode: tokens None loads token data via the store (a corrupt tokens.json raises :class:TokenStoreError). Callers inside a health run pass the single token view loaded once by :func:run_health_check ({} when corrupt) so enumeration never re-reads the file.
#_managed_profiles
def _managed_profiles(ws: 'Workspace', tokens: dict[str, Any] | None=None) -> list[Profile]Discovered profiles EXCLUDING the vanilla "default" (~/.claude).
~/.claude is Claude Code's own config dir -- managed by Claude Code, not cw, and strictly read-only to cw. It is therefore EXEMPT from every guardrail/settings health check (shared symlinks, hook wiring, settings defaults, canonical/shared drift, relocated hook paths) and from the cw token check (it legitimately has no cw-managed token). Non-guardrail integrity checks (auth shadow, file permissions) still see it via :func:_discover_profiles, where a bare ~/.claude self-skips harmlessly.
#check_shared_symlinks
def check_shared_symlinks(ws: 'Workspace', tokens: dict[str, Any] | None=None) -> HealthResultVerify each profile's shared dirs are symlinks to ~/.claudewheel/shared/.
#_hook_wired
def _hook_wired(hooks: object, event: str, matcher: str, script: str, scripts_dir: Path) -> boolReturn True if hooks wires script under event with matcher.
An entry matches when its matcher equals matcher (an absent matcher is treated as the empty string, which is how UserPromptSubmit entries are stored) and it carries a hook command equal to the EXACT canonical command for script under scripts_dir. Exact-match (not substring) so a hook pointing at a stale/dead scripts directory -- right basename, wrong root -- does NOT pass, which a substring match would have wrongly accepted.
#check_hooks_wired
def check_hooks_wired(ws: 'Workspace', tokens: dict[str, Any] | None=None) -> HealthResultVerify each profile wires every expected hook in settings.json.
The canonical wirings are the (event, matcher, script-name) triples in guardrail.EXPECTED_HOOK_WIRINGS. A profile passes only when every triple is present: an entry under the given event whose matcher equals the given matcher, containing a hook command equal to the exact canonical command (scripts_dir / script) for that triple. A hook pointing at the right basename under the wrong directory does NOT satisfy the wiring.
#check_settings_defaults
def check_settings_defaults(ws: 'Workspace', tokens: dict[str, Any] | None=None) -> HealthResultVerify each profile enforces expected defaults in settings.json.
#_diff_json
def _diff_json(label: str, canonical: object, actual: object) -> list[str]Return human-readable lines describing differences between two JSON values.
#check_shared_settings_drift
def check_shared_settings_drift(ws: 'Workspace', tokens: dict[str, Any] | None=None) -> HealthResultCompare each profile's hooks and disallowedTools against shared-settings.json.
#_canonical_permission_diffs
def _canonical_permission_diffs(label: str, perms: object) -> list[str]Return drift lines comparing a permissions block against the canonical model.
Checks permissions.deny and permissions.ask against guardrail.canonical_deny_rules() / canonical_ask_rules() (reporting missing canonical entries and extra non-canonical ones) and flags any permissions.allow entry that is a known dead/conflicting allow (guardrail.ALLOW_CONFLICTS).
#check_canonical_permissions_drift
def check_canonical_permissions_drift(ws: 'Workspace', tokens: dict[str, Any] | None=None) -> HealthResultCompare each profile's permissions against the canonical guardrail model.
For every profile settings.json and for shared-settings.json's profileDefaults (which seeds new profiles), verify that permissions.deny / permissions.ask match the canonical guardrail rules exactly and that no permissions.allow entry is a known dead/conflicting allow. Reports MISSING canonical entries, EXTRA non-canonical entries, and conflicting allows per profile. Warnings only -- never raises; ok is True only when everything matches and no conflicts exist.
#check_auth_shadow
def check_auth_shadow(ws: 'Workspace', tokens: dict[str, Any] | None=None) -> HealthResultDetect profiles where .credentials.json claudeAiOauth shadows a long-lived token.
#check_token_expiry
def check_token_expiry(ws: 'Workspace', tokens: dict[str, Any] | None=None, token_error: TokenStoreError | None=None) -> HealthResultWarn if any token is approaching 1-year expiry (setup-token TTL).
Token corruption surfaces here as a FAILED check: a token_error recorded by the single run-level load, or (for standalone calls) a fresh :class:TokenStoreError raised while loading. The actionable exception message is the detail.
#check_tokens
def check_tokens(ws: 'Workspace', tokens: dict[str, Any] | None=None, token_error: TokenStoreError | None=None) -> HealthResultVerify each profile has a matching entry in ~/.claudewheel/tokens.json.
A corrupt tokens.json is the FAILED-check carve-out: when token_error is recorded (single run-level load) or a standalone call hits a :class:TokenStoreError, this check fails with the exception's actionable message instead of crashing the whole run.
#check_orphan_profiles
def check_orphan_profiles(ws: 'Workspace', tokens: dict[str, Any] | None=None) -> HealthResultDetect profile dirs in ~/.claudewheel/profiles/ that are not registered.
A directory is "orphan" if it: - lives in ~/.claudewheel/profiles/ - is NOT discovered by _discover_profiles() (which checks .credentials.json, settings.json, and tokens.json) - is NOT listed in options.json's profile values
For each orphan, we also flag if it contains broken symlinks (symlinks whose target does not exist).
#check_orphan_token_entries
def check_orphan_token_entries(ws: 'Workspace', tokens: dict[str, Any] | None=None, token_error: TokenStoreError | None=None) -> HealthResultDetect tokens.json keys whose profile directory no longer exists.
A stale/orphan token entry is a tokens.json key with no profile dir behind it -- silently absent from profile enumeration, so it never surfaces as a profile. Consumes :meth:ProfileStore.audit (kind "orphan-token-entry"). Zero findings -> OK. Distinct from check_orphan_profiles, which flags orphan profile DIRECTORIES rather than orphan TOKEN ENTRIES.
A corrupt tokens.json is the FAILED-check carve-out: a recorded token_error (from the single run-level load) surfaces the actionable message here.
#check_file_permissions
def check_file_permissions(ws: 'Workspace', tokens: dict[str, Any] | None=None) -> HealthResultVerify sensitive files have restrictive permissions (0600).
#check_inode_renames
def check_inode_renames(ws: 'Workspace') -> HealthResultDetect directory renames by comparing inode records against the filesystem.
#check_deployed_hook_drift
def check_deployed_hook_drift(ws: 'Workspace') -> HealthResultCompare deployed hook scripts against the generated HOOK_SCRIPTS model.
Byte-hashes each script deployed under scripts_dir against the corresponding HOOK_SCRIPTS[name] string (the canonical model, generated from the guardrail spec at import). Drift means a deployed script no longer matches what claudewheel deploy-hooks would write -- usually a stale copy left over after the model was regenerated.
Warn-only: reports drift but NEVER raises and is never a hard gate. Absence is not drift: if scripts_dir does not exist (CI, fresh machines) or an individual model script has not been deployed yet, it is skipped and the check stays OK. Only the scripts present in both HOOK_SCRIPTS and on disk are compared.
#_stale_hook_command_paths
def _stale_hook_command_paths(hooks: object, scripts_dir: Path) -> list[str]Return claudewheel-managed hook commands NOT rooted at scripts_dir.
Walks every hook command under hooks and considers only commands whose basename is a known claudewheel hook script (HOOK_SCRIPTS). A managed command is "stale" when its parent directory is not scripts_dir -- i.e. it points at a scripts directory left behind by a workspace relocation. Commands for user-custom (non-claudewheel) scripts are ignored entirely, so unrelated hooks under any directory are preserved.
#check_relocated_hook_paths
def check_relocated_hook_paths(ws: 'Workspace', tokens: dict[str, Any] | None=None) -> HealthResultDetect hook commands pointing at a scripts dir other than the current one.
The deployed-hook drift check compares script CONTENT hashes and so cannot see a hook whose command still references a STALE absolute scripts directory after the workspace was relocated (the substring matcher in check_hooks_wired also passes for a stale root). This check closes that blind spot: for shared-settings.json and every profile's settings.json, it flags any claudewheel-managed hook command whose parent directory is not the current scripts_dir. Intact (current-root) and absent hooks pass; claudewheel patch-profiles repaths any it finds.
#run_health_check
def run_health_check(ws: 'Workspace') -> list[HealthResult]Run all health checks and return results.
Token data is loaded ONCE here (the single-load carve-out): a corrupt tokens.json does not crash the run -- the error is recorded, {} is used as the explicit token view so every profile-based check still runs (profiles enumerate dir-only, has_token False), and the recorded error surfaces as a FAILED token check via check_tokens / check_token_expiry.
#print_health_report
def print_health_report(results: list[HealthResult], file: IO[str] | None=None) -> NonePrint health check results. Defaults to stdout; pass file=sys.stderr for non-interactive mode.