Skip to content

Security Policies

User config is per-user, which is the sensible default for most setups. On a shared workstation, in CI, or on a managed fleet, that stops being enough. Someone has to be able to say “the floor for RSA is 4096” or “vaults live under /srv/secrets/ and nowhere else”, and have that survive any user editing their own ~/.config/dotsecenv/config.yaml.

That is what the policy directory is for. It’s a flat tree of YAML fragments under /etc/dotsecenv/policy.d/ that an administrator drops in. dotsecenv reads it on every startup, no daemon involved. The fragments are owned by root, mode 0644 or stricter, and they get unioned and overlaid on top of whatever the user wrote.

If you have used sudoers.d or systemd drop-ins, the model will feel familiar. If not, the rules below are short.

When dotsecenv starts, it reads three things in order:

  1. The user’s config (~/.config/dotsecenv/config.yaml or -c <path>).
  2. Every *.yaml file in /etc/dotsecenv/policy.d/, lexically sorted.
  3. Anything passed on the command line (e.g. -v <vault>).

Then it merges policy with user config. Allow-list fields (algorithms, vault paths) are intersected, so a value is allowed only if both the user and the policy permit it. Scalar fields (gpg path, behavior toggles) are overridden, so any policy fragment that sets one wins over the user.

When this filters something out, dotsecenv prints a warning to stderr explaining what changed. Nothing is silently dropped. If the warnings are noise in scripts, -s suppresses them.

A minimal example. Suppose the user wrote:

~/.config/dotsecenv/config.yaml
approved_algorithms:
- algo: RSA
min_bits: 2048
- algo: ECC
curves: [P-256, P-384]
min_bits: 256
vault:
- ~/personal-secrets/vault
- /srv/secrets/team-a/vault

And the admin dropped this into the policy directory:

/etc/dotsecenv/policy.d/50-org-baseline.yaml
approved_algorithms:
- algo: ECC
curves: [P-384, P-521]
min_bits: 384
approved_vault_paths:
- /srv/secrets/*/vault
behavior:
restrict_to_configured_vaults: true

Running dotsecenv vault describe now produces:

warning: policy excludes RSA from your approved_algorithms
warning: policy raises min_bits for ECC from 256 to 384
warning: policy and your approved_algorithms have no common curves for ECC; this algorithm is now unusable
warning: policy filters vault path /home/alice/personal-secrets/vault (not matched by approved_vault_paths: [/srv/secrets/*/vault])

RSA is gone. P-256 is gone. The personal vault is gone. Whatever survives the intersection runs.

Policy fields fall into two categories with different merge semantics. They are easier to reason about when you keep the two straight.

Allow-lists (intersected, union across fragments)

Section titled “Allow-lists (intersected, union across fragments)”

These describe what the user is permitted to do. Multiple fragments are unioned (most permissive wins between fragments), then intersected with the user’s own allow-list (least permissive wins).

FieldWhat it controls
approved_algorithmsWhich key algorithms, curves, and minimum bit-sizes the user may use
approved_vault_pathsWhich paths the user’s vault: entries (and -v flags) may resolve to

The intersection is the safety net. A user can never gain capability by writing something the policy did not allow. They can only lose capability the policy takes away.

Scalars (last-fragment-to-set wins, overrides user)

Section titled “Scalars (last-fragment-to-set wins, overrides user)”

These describe how dotsecenv behaves at runtime. Multiple fragments are merged in lexical order, and the last fragment to set a field wins. The result then overrides whatever the user wrote.

FieldWhat it controls
behavior.require_explicit_vault_upgradeForce users to run vault upgrade rather than auto-upgrading
behavior.restrict_to_configured_vaultsReject -v flags; only honor vaults from config
gpg.programPin the GPG binary path (e.g. /usr/bin/gpg)

If two fragments disagree on a scalar, dotsecenv prints a policy conflict warning so admins notice the disagreement. The conflict still resolves (last-set wins), but the warning is the breadcrumb to fix it.

For behavior toggles, “unset” and “set to false” are different things. A fragment that omits restrict_to_configured_vaults does not constrain it. A fragment that writes restrict_to_configured_vaults: false is actively saying “the answer is false”, and that wins over an earlier fragment that said true.

This matters when you compose fragments. If you want 99-overrides.yaml to relax a setting back to false, write it explicitly. An omission will not undo an earlier true.

Drop a .yaml file in /etc/dotsecenv/policy.d/. There is no registration step.

A few conventions, plus one hard rule:

  • Filename ordering matches the Unix *.d convention: files load lexically. The community pattern is 00-base, 50-team, 99-overrides. Leave room between names so future fragments can slot in.
  • Filename order does not matter for allow-lists (they union). It does matter for scalars (last-set wins).
  • The hard rule: owner must be root, mode must be 0644 or stricter. World-writable, group-writable, or non-root-owned policy files are rejected at load time. See “When policy goes wrong” below for what happens then.

A fragment may set:

approved_algorithms: # allow-list
approved_vault_paths: # allow-list
behavior: # scalars
require_explicit_vault_upgrade:
restrict_to_configured_vaults:
gpg: # scalar
program:

A fragment may not set login: or vault:. Identity and vault paths belong to the user, not the admin. Setting either in a fragment is a hard error at load time (ExitConfigError).

Empty allow-lists are also an error. Writing approved_algorithms: [] would mean “no algorithm is approved”, which would lock everyone out, so dotsecenv refuses. Omit the field entirely if you do not want to constrain it.

Policy is fail-closed. If dotsecenv cannot load the policy directory cleanly, it does not fall back to “no policy”. It refuses to start. The whole point of admin policy is that users can’t sidestep it; degrading silently to user-only would defeat that.

Errors map to distinct exit codes so CI can tell them apart:

ConditionExit code
Insecure permissions on policy.d/ or a fragment8 (Access denied)
Unreadable fragment (I/O error, broken symlink)8 (Access denied)
Forbidden key in a fragment (login, vault)2 (Configuration error)
Malformed YAML2 (Configuration error)
Empty allow-list (approved_algorithms: [])1 (General error)

The two CLI commands below (policy list and policy validate) are deliberately standalone. They keep working when the policy is broken, which is the moment you actually need to inspect it. They do not load the user config or open vaults.

If /etc/dotsecenv/policy.d/ does not exist at all, that is not an error. dotsecenv runs as if no policy were configured.

Two commands, neither requiring root.

Print the effective merged policy with per-field origin attribution.

$ dotsecenv policy list
Policy directory: /etc/dotsecenv/policy.d (2 fragment(s))
approved_algorithms:
- algo: ECC, curves: [P-384, P-521], min_bits: 384 [50-org-baseline.yaml]
approved_vault_paths:
- /srv/secrets/*/vault [50-org-baseline.yaml]
behavior:
restrict_to_configured_vaults: true [50-org-baseline.yaml]
gpg.program: /usr/bin/gpg [99-overrides.yaml]

Each entry is annotated with the fragment(s) it came from. For allow-lists, multiple origins mean the entry was contributed by more than one fragment. For scalars, the origin is the last fragment that set the value.

--json returns the same data as a structured object:

Terminal window
dotsecenv policy list --json

Parse all fragments, check structure, report. Designed to run in CI on the ops repo that ships your policy:

$ dotsecenv policy validate
✓ policy valid (2 fragment(s) in /etc/dotsecenv/policy.d)

--json produces a flat object compatible with the convention used by vault doctor --json:

{
"dir": "/etc/dotsecenv/policy.d",
"valid": true,
"fragment_count": 2
}

On failure, valid is false and an error block carries the matching exit code:

{
"dir": "/etc/dotsecenv/policy.d",
"valid": false,
"fragment_count": 0,
"error": {
"exit_code": 8,
"message": "policy fragment /etc/dotsecenv/policy.d/50-org.yaml has insecure permissions: mode 0666"
}
}

approved_vault_paths uses Go’s filepath.Match glob. That means single-segment matching, like a shell glob without **:

PatternMatchesDoesn’t match
/srv/secrets/*/vault/srv/secrets/team-a/vault/srv/secrets/a/b/vault
/srv/secrets/*/srv/secrets/vault/srv/secrets/team-a/vault
~/secrets/*/home/alice/secrets/vault (after ~ expansion)
/srv/secrets/team-?/vault/srv/secrets/team-a/vault/srv/secrets/team-ab/vault

~ is expanded to the running user’s home directory before matching. Relative paths in user config (e.g. vault: [./project/vault]) are normalized to absolute paths first.

Pick patterns conservatively. A bare * allows anything, which may be fine for a permissive baseline but defeats the point if you meant to restrict.

A handful of fragments that show how the pieces compose.

/etc/dotsecenv/policy.d/00-baseline.yaml
approved_algorithms:
- algo: ECC
curves: [P-384, P-521]
min_bits: 384
- algo: EdDSA
curves: [Ed25519]
min_bits: 255
gpg:
program: /usr/bin/gpg
/etc/dotsecenv/policy.d/50-team-paths.yaml
approved_vault_paths:
- /srv/secrets/shared/vault
- /srv/secrets/team-*/vault

Allow-lists union, so this adds to whatever earlier fragments contributed. It does not replace them.

/etc/dotsecenv/policy.d/99-ci.yaml
approved_vault_paths:
- /workspace/vault
behavior:
restrict_to_configured_vaults: true
require_explicit_vault_upgrade: true

Combined with the baseline above, CI images get a single permitted vault path, no -v overrides, and no implicit format upgrades during a build.

/etc/dotsecenv/policy.d/99-mac.yaml
gpg:
program: /opt/homebrew/bin/gpg

Last-set wins. 99-mac.yaml loads after 00-baseline.yaml, so on Macs the Homebrew GPG path takes effect. The user sees a policy conflict on gpg.program warning explaining where the value came from.

Every policy effect emits a warning to stderr. Most of the time you want this; the user should know their config was filtered. In scripts and CI where the warnings are noise, pass -s:

Terminal window
dotsecenv -s secret get DATABASE_URL

-s only suppresses warnings. Errors still print, and exit codes are unchanged.

A few things the policy directory is not.

  • Per-user policy. All users on the host get the same policy. If you need per-user rules, that is what user config is for, pinned by what admin policy allows.
  • Remote policy. No HTTP fetch, no signed URL. To distribute fragments across a fleet, use configuration management (Ansible, Chef, MDM, image bake).
  • Policy on login: or vault:. Identity and vault paths belong to the user. Use approved_vault_paths to constrain where vaults may live; the user still chooses which to use.
  • Windows. Not yet supported. The permission check assumes POSIX semantics, and the policy loader returns an error on Windows.

If /etc/dotsecenv/policy.d/ does not exist, none of this is in effect. dotsecenv runs entirely from user config.