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.
How it works
Section titled “How it works”When dotsecenv starts, it reads three things in order:
- The user’s config (
~/.config/dotsecenv/config.yamlor-c <path>). - Every
*.yamlfile in/etc/dotsecenv/policy.d/, lexically sorted. - 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:
approved_algorithms: - algo: RSA min_bits: 2048 - algo: ECC curves: [P-256, P-384] min_bits: 256vault: - ~/personal-secrets/vault - /srv/secrets/team-a/vaultAnd the admin dropped this into the policy directory:
approved_algorithms: - algo: ECC curves: [P-384, P-521] min_bits: 384approved_vault_paths: - /srv/secrets/*/vaultbehavior: restrict_to_configured_vaults: trueRunning dotsecenv vault describe now produces:
warning: policy excludes RSA from your approved_algorithmswarning: policy raises min_bits for ECC from 256 to 384warning: policy and your approved_algorithms have no common curves for ECC; this algorithm is now unusablewarning: 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.
Two shapes of policy
Section titled “Two shapes of policy”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).
| Field | What it controls |
|---|---|
approved_algorithms | Which key algorithms, curves, and minimum bit-sizes the user may use |
approved_vault_paths | Which 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.
| Field | What it controls |
|---|---|
behavior.require_explicit_vault_upgrade | Force users to run vault upgrade rather than auto-upgrading |
behavior.restrict_to_configured_vaults | Reject -v flags; only honor vaults from config |
gpg.program | Pin 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.
A word on “set”
Section titled “A word on “set””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.
Writing fragments
Section titled “Writing fragments”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
*.dconvention: files load lexically. The community pattern is00-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
0644or 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-listapproved_vault_paths: # allow-listbehavior: # 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.
When policy goes wrong
Section titled “When policy goes wrong”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:
| Condition | Exit code |
|---|---|
Insecure permissions on policy.d/ or a fragment | 8 (Access denied) |
| Unreadable fragment (I/O error, broken symlink) | 8 (Access denied) |
Forbidden key in a fragment (login, vault) | 2 (Configuration error) |
| Malformed YAML | 2 (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.
Inspecting policy
Section titled “Inspecting policy”Two commands, neither requiring root.
dotsecenv policy list
Section titled “dotsecenv policy list”Print the effective merged policy with per-field origin attribution.
$ dotsecenv policy listPolicy 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:
dotsecenv policy list --jsondotsecenv policy validate
Section titled “dotsecenv policy validate”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" }}Path matching
Section titled “Path matching”approved_vault_paths uses Go’s filepath.Match glob. That means single-segment matching, like a shell glob without **:
| Pattern | Matches | Doesn’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.
Real-world patterns
Section titled “Real-world patterns”A handful of fragments that show how the pieces compose.
Pin an organization baseline
Section titled “Pin an organization baseline”approved_algorithms: - algo: ECC curves: [P-384, P-521] min_bits: 384 - algo: EdDSA curves: [Ed25519] min_bits: 255gpg: program: /usr/bin/gpgLayer team-specific vault paths
Section titled “Layer team-specific vault paths”approved_vault_paths: - /srv/secrets/shared/vault - /srv/secrets/team-*/vaultAllow-lists union, so this adds to whatever earlier fragments contributed. It does not replace them.
Lock down a CI image
Section titled “Lock down a CI image”approved_vault_paths: - /workspace/vaultbehavior: restrict_to_configured_vaults: true require_explicit_vault_upgrade: trueCombined with the baseline above, CI images get a single permitted vault path, no -v overrides, and no implicit format upgrades during a build.
Override a baseline value
Section titled “Override a baseline value”gpg: program: /opt/homebrew/bin/gpgLast-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.
Quieting warnings
Section titled “Quieting warnings”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:
dotsecenv -s secret get DATABASE_URL-s only suppresses warnings. Errors still print, and exit codes are unchanged.
Out of scope
Section titled “Out of scope”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:orvault:. Identity and vault paths belong to the user. Useapproved_vault_pathsto 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.