Skip to content
Jake Paine edited this page Apr 20, 2026 · 4 revisions

Doctor

keyseal doctor inspects your repository and reports on its health. It is non-destructive - it never writes files or modifies configuration. Exit code is 0 if all checks pass or warn; exit code is 1 if any check fails.

Running doctor

keyseal doctor

Text output:

doctor summary: 9 ok, 0 warning(s), 0 failure(s), 0 skipped
[ok] keyseal config: keyseal.yaml loaded and valid
[ok] sops config: .sops.yaml loaded; 2 creation rules
[ok] sops creation rules: 2 of 2 rules have usable recipients
[ok] sops placeholder check: no placeholder recipients detected
[ok] repository root: .
[ok] output path safety: file mode 0600 is owner-only
[ok] output paths: no relative output paths detected
[ok] repo artifacts: no unexpected artifacts in repository root
[ok] sops binary: /usr/local/bin/sops (version: 3.8.1)
[ok] secret files: 3 encrypted file(s) discovered
...

For machine-readable output:

keyseal doctor --json
{
  "summary": {
    "ok": 8,
    "warn": 1,
    "fail": 0,
    "skip": 0,
    "total": 9
  },
  "checks": [
    {
      "name": "keyseal config",
      "status": "ok",
      "severity": "informational",
      "summary": "keyseal.yaml loaded and valid",
      "details": ["root: .", "extension: .enc.yaml", "binary: sops"]
    },
    ...
  ]
}

Check reference

Checks run in a fixed order. Some checks gate later checks - if config fails to load, secret validation is skipped.


keyseal config

What it checks: Whether keyseal.yaml exists in the current directory and loads without errors.

Fail conditions:

  • keyseal.yaml does not exist
  • File exists but fails config.Load (invalid YAML, wrong version, bad regexp in key_pattern, etc.)

Details on pass: reports the resolved root, encrypted_extension, and sops.binary values.

Remediation:

# if missing:
keyseal init

# if invalid, review the error message from:
keyseal doctor

sops config

What it checks: Whether .sops.yaml exists in the current directory and is parseable.

This check runs independently of the config check - even if keyseal.yaml fails to load, .sops.yaml is still inspected.

Fail conditions:

  • .sops.yaml does not exist
  • File exists but cannot be parsed as YAML

Details on pass: reports the number of creation rules found.


sops creation rules

What it checks: Whether the SOPS creation rules have usable recipients configured.

Fail conditions:

  • No creation rules exist in .sops.yaml
  • Rules exist but none have any usable recipient material (no age, pgp, kms, gcp_kms, azure_keyvault, or hc_vault_transit_uri keys with non-empty content, and no key_groups with recipients)

Details on pass: reports how many rules have usable recipients.


sops placeholder check

What it checks: Whether .sops.yaml contains placeholder text that was not replaced after keyseal init.

The check scans the raw file text for the pattern:

\bage1[A-Za-z0-9_]*REPLACE_ME[A-Za-z0-9_]*\b|\bREPLACE_ME\b

Fail conditions: Any match found. The check reports each unique placeholder string found.

Why this matters: SOPS will accept these as age recipients syntactically but they do not correspond to any real key. Files "encrypted" with placeholder recipients cannot be decrypted by anyone.

Remediation: Replace every placeholder in .sops.yaml with a real age public key:

# generate a new key pair
age-keygen -o ~/.config/sops/age/keys.txt
# the public key is printed; paste it into .sops.yaml

repository root

What it checks: Whether repository.root (from keyseal.yaml) exists as a directory.

Fail conditions:

  • The path does not exist
  • The path exists but is not a directory

If this check fails, secret discovery is skipped.


output path safety

What it checks: The defaults.file_mode value for unsafe permissions.

Warn conditions: The mode has group or world bits set (anything other than owner-only). Example: 0644 produces a warning; 0600 passes.

Doctor warns rather than fails - having a non-strict default mode is a configuration choice, not necessarily an error. But render --out will refuse to use an unsafe mode unless --force is passed.


output paths

What it checks: Whether defaults.output_dir and any paths in profiles.*.renders[*].out are relative paths or use path traversal.

Fail conditions: Empty path, path traversal (../ prefix after filepath.Clean).

Warn conditions: A path that is valid but relative (relative paths make it easy to accidentally commit rendered output files).

Note: The profiles section is not currently executed by any command, but these paths are still validated so you do not have insecure or surprising defaults when profiles are used.


repo artifacts

What it checks: Whether unexpected build artifacts are present in the repository root.

Warn conditions:

  • A regular file named keyseal exists in <repository.root> (a compiled binary left in the repo)
  • A non-empty dist/ directory exists in <repository.root> (release archives left behind)

Neither of these causes a failure - they are hygiene warnings. Compiled binaries and dist archives should not be committed to the repository.


sops binary

What it checks: Whether the configured SOPS binary is available and executable.

Fail conditions:

  • Binary not found on PATH (or not at the configured path)

Warn conditions:

  • Binary is found and runs, but sops --version produces no output

Details on pass: reports the resolved binary path and the version string from sops --version.

If the binary check fails, all decrypt-based secret validation is skipped.


secret files

What it checks: Counts the encrypted files discovered under repository.root.

Discovery walks the entire repository tree (skipping .git) and collects all files ending in the configured encrypted_extension.

Pass with zero files: reports "No encrypted secret files were discovered yet" - not a failure, just informational.

Pass with files: reports the count.

Per-file checks follow:


Per-file checks (name <relative path>)

For each discovered file, doctor runs:

  1. Logical name mapping: verifies PathToLogicalName succeeds (path maps to a valid logical name that does not escape the repo root)
  2. Round-trip validation: verifies LogicalNameToPath of the mapped name produces the original path
  3. Plaintext detection: if the file parses as a valid EnvSecretDocument AND does not contain SOPS metadata, it is flagged as FAIL: appears to be plaintext rather than SOPS-encrypted content
  4. Decrypt (if SOPS available): calls sops --decrypt <file>; failure is reported as FAIL with up to 3 lines of SOPS error output
  5. Schema parse: checks decrypted output is valid YAML with the expected document structure
  6. Validate: checks all fields and key patterns against config validation rules
  7. Duplicate key check: if any duplicate keys in values, reports WARN listing the duplicates

If SOPS is not available (binary check failed), decrypt and subsequent steps are skipped and reported as skip.


How to fix common failures

Finding Fix
keyseal.yaml missing Run keyseal init
keyseal.yaml invalid Check the error message; usually a wrong version, bad file_mode, or invalid regexp
.sops.yaml missing Run keyseal init or create .sops.yaml manually
Placeholder recipients Replace age1REPLACE_ME values in .sops.yaml with real age public keys
Plaintext at .enc.yaml path Re-encrypt: rm <file>; keyseal add <logical-name>, then edit with keyseal edit
SOPS binary not found Install sops or set sops.binary to the correct path in keyseal.yaml
sops decrypt fails Check that your age private key is at the expected path and you have access to the key
Unsafe file mode Update defaults.file_mode in keyseal.yaml to "0600" or use --force deliberately
Relative output path Use an absolute path in defaults.output_dir
keyseal binary in repo Delete it; add /keyseal to .gitignore
dist/ in repo Delete or move it; add /dist/ to .gitignore

Using JSON output in scripts

# get all failing checks
keyseal doctor --json | jq '.checks[] | select(.status == "fail")'

# count failures
keyseal doctor --json | jq '.summary.fail'

# extract remediation steps for failures
keyseal doctor --json | jq '.checks[] | select(.status == "fail") | .remediation[]'

Clone this wiki locally