Reference

Command line#

ljos-policyd check|exec|capnp|version -- argv...

ljos-policyd check-mail --config PATH [--argv [--] RECIPIENT...] reads one RFC 5322 message on stdin. See “Outbound mail” below.

Verb

Meaning

check

print allow or deny<TAB>reason; exit 2 on deny

capnp

write a packed Cap’n PolicyDecision to stdout; exit 2 on deny

exec

same text verdict; on allow, run argv; on deny, print the verdict on stderr and exit 2

check-mail

JSON allow or deny for one message on stdin; exit 2 when it refuses

version

print ljos-policyd <semver> (phronesis) or ljos-policyd <semver> (host table)

A leading -- after the verb is optional and is stripped.

Built-in denials#

First match wins. The head is the last path component of argv[0]. Leading assignments, NAME=value or NAME+=value, and runner words such as env, nice and timeout come off first, as does the head of a case arm (case $x in a), or b) after ;;). A trap action is judged as a script of its own. A shell with no -c script and no file runs what it reads on stdin: the text of a here-string or heredoc is judged as that script, and a pipe, a -s shell fed neither, or a here-string holding an expansion is refused as shell-stdin-unshown. A command word that an alias earlier on the line names is read as the alias’s body, since dash and zsh expand aliases in a script and the line does not say which shell reads it.

Token

Cap’n PolicyReason

When

empty argv

emptyArgv

no program

sudo

shellPrivilegeDenied

privilege runner

curl-pipe-shell

shellRemoteExec

a download run as a program

rm-rf-outside-tmp

rmRfOutsideTmp

recursive delete off tmp

git-force-push

shellGitDangerous

force push or guarded lease

chmod-setuid

chmodSetuid

setuid or setgid mode

raw-disk

rawDisk

dd to a device node, or mkfs

Anything else is allow. python -c is allow. Schema: schema/policy.capnp.

Task runners#

A line that runs a task runner is also judged on what the target runs. The recipe comes from the file the runner reads, in the directory it runs in: the working directory, the one a cd or pushd earlier on the line enters, the one env -C names for the command it runs, or the one the runner’s own -C, --prefix or --cwd names. env reads its flags as GNU env does: short ones cluster (-iC DIR, -uVAR -C DIR) and a long one may be cut to a prefix that names only it (--ch DIR), so these move the command as -C DIR does. -S STRING (or -iS STRING) splits STRING into words that take its place, so options in it (env -S '-C dir' make) and the words after it are read too. The shell expands STRING first, so a $NAME or ${NAME} in it that the line assigned one value earlier, and sets no other way, is read as that value. A string that still holds a $ after that may run any command, so it is refused as env-split-string-unshown. Leading words that still run cd in this shell (command, builtin, if, while, { and the other control keywords, a case arm’s head, and the time keyword with its -p, which is a program after command or builtin, where no word is a keyword) are peeled before this check, as are assignments among them (if FOO=1 cd dir), and a redirection among the words of a cd or pushd names neither the directory nor a pushd stack index. One that closes a descriptor (2>&-, <&-) is a whole word, so the word after it is the target.

Runner

File read

make, gmake

GNUmakefile, makefile or Makefile, or -f FILE, with the target’s prerequisites

just

justfile, Justfile or .justfile

cargo make, makers

Makefile.toml

npm run, pnpm, yarn, bun run

the scripts of package.json, with pre and post

Each recipe line is judged as a sh -c script, and a refusal names the target and the line: make clean-all runs `rm -rf ~`: rm-rf-outside-tmp. Nothing is spawned. A missing or unreadable file leaves the line judged as written. A cd or pushd to a path the line does not show (cd "$X") leaves it in a directory it does not follow, so a runner after it is refused. So does an eval, a source or . whose file the guard cannot read or whose text may move the shell or set one of those, a command word the line does not show ($c dir), which may be cd, as may an alias whose body the line does not show, and a trap whose action is one of those or a cd, pushd or popd, since a DEBUG trap runs before each command; after one HOME, CDPATH and PWD are not followed either. A source or . of a literal path to a regular file the guard can read, whose text does none of that, leaves the line where it was. The text is read with the aliases the line defined before the source expanded, as the shell that sources it reads it. Raw newlines and carriage returns split script commands, and $'...' ANSI-C quoted words are decoded before the line is judged. A backslash before a newline joins the two lines, outside single quotes, as the shell joins them.

A cd or pushd to a relative path whose first part is not . or .. enters that path under the first CDPATH entry that holds it, an empty or . entry being the working directory, so the runner after it is read there, and in any entry before it the line does not show. A CDPATH set in front of the cd (CDPATH=dir cd x), or of the shell that runs it, is the one searched, as is one the line sets before it, alone or with export, declare and the like (export CDPATH=dir; cd x), unless a pipeline stage, a subshell or a list sent to the background set it. One set where it may not run, past a && or ||, is searched along with the one before it. Another command that writes it in this shell (read CDPATH, printf -v CDPATH, ${CDPATH:=dir}, or an assignment in front of a special builtin such as :), or an entry with $, a backquote or ~ in it, names no directory the line shows, so a runner after the cd is refused. A word that only names it (grep CDPATH, env CDPATH=dir cmd) changes nothing.

HOME is followed the same way: a bare cd enters the one set in front of it (HOME=dir cd) or earlier on the line (export HOME=dir; cd), and ~ expands from the one set before the command, as do $HOME and ${HOME} at the head of a path. A HOME that may hold either of two values, or one the line does not show (HOME=$(mktemp -d) cd), leaves the runner after the cd refused. $PWD in either value is the one directory the line is in, when it shows that and does not set PWD (export HOME”$PWD/.home”). With =HOME unset, ~ is the home in the user’s passwd entry, as in bash, and a bare cd stays. With it unset or empty, $HOME/path is the path from /, as is ~/path with it empty, while cd $HOME, or cd ~ with it empty, stays. ~login expands from the password database and ~+ to where the line is, while ~- and a stack entry such as ~1 name a directory that is not followed.

A cd or pushd takes .. off the path by name, as the shell’s logical cd does, so cd lnk/.. comes back to where it started even when lnk is a symbolic link. Where that names no directory, the line may instead be through the link, as bash then tries the path as written (cd lnk/../x with x only beside the link’s target). cd -P, the last of -L and -P winning, resolves the link first, and after a set -P or set -o physical on the line, or a SHELLOPTS that may name physical for a bash the line starts, either may hold. A source or . whose file runs set -P leaves the line in a directory it does not follow. env -C resolves it, as chdir does.

pushd and popd follow the directory stack. pushd -n stacks its directory without entering it, pushd +N and -N enter the entry they bring to the top, and a bare popd enters the entry under the top, while popd -n, popd +N and popd -N drop an entry and stay. An index that counts past a pushd that may have failed may name any directory on the stack. A popd that fails, on a word it does not take (popd x) or with nothing left to pop (after dirs -c), leaves the line in a directory it does not follow. Under shopt -s lastpipe or set -o lastpipe, alone or in an eval, or a shopt with a word the line does not show (shopt -s $opt), and in a shell the line starts after setting BASHOPTS or SHELLOPTS to a value that names lastpipe or that it does not show, a cd, pushd or popd in the last stage of a pipeline does the same, and a dirs there leaves the stack not followed.

The line goes on past a cd that may fail, so the runner after it is read in each directory the line may be in, up to sixteen. A cd into a directory without the search bit moves nothing. A runner that may run in a directory past those sixteen is refused, as make clean-all may run in an untracked directory.

A cd - or pushd - enters OLDPWD, which each cd, pushd and popd on the line moves, and a script the line runs starts with the one its command has. Without OLDPWD in the environment, and in a recipe’s line, where it is not followed, a runner after cd - is refused the same way.

The words xargs adds from stdin can name any target, so a runner that xargs runs is refused, as xargs make may take a target from stdin.

Argument limits#

A phronesis build hands each word to the pack whole. phronesis 0.2.3 keeps a word of up to 128 KiB (131072 bytes) and up to 32768 words. A Cap’n command body over 512 KiB (524288 bytes) is refused before the word count is checked. Past a limit the answer is field-too-long and the host’s reason:

Past

Answer

a word longer than 131072 bytes

field-too-long: argv word exceeds 131072 bytes

more than 32768 words

field-too-long: argv count exceeds 32768

a command body longer than 524288 bytes

field-too-long: command body exceeds 524288 bytes

A blank reason is the name alone, field-too-long. The limits are PHRONESIS_SHELL_WORD_MAX and PHRONESIS_SHELL_ARGV_MAX in phronesis src/internal.h, and PHRONESIS_CAPNP_MAX_BODY in phronesis include/phronesis/supervisor.h. phronesis 0.2.3 sets them to 131072, 32768 and 524288 and writes the reason strings above. This binary names the code and keeps the reason. Release binaries link phronesis 0.2.5. A build without the library uses the host table and does not apply these limits.

Outbound mail#

check-mail prints one JSON object and only refuses. allow means this check found nothing to refuse. A send still needs its own grant.

The message on stdin is the whole input. A caller must pass no recipient arguments to sendmail -t: those addresses are not in the message. Pass the same recipients to --argv and this check refuses them as recipients. --config comes before --argv.

The config is lines of identity, deny-file and pack-law, an optional subaddress line, and any number of alias lines. A relative path is read from the config file’s directory. identity is the one address From must be, after ASCII case folding only. A local part or domain that NFKC or the removal of a skipped character would change is a refusal, including a zero-width character or a fullwidth spelling. The mailbox is the address in angle brackets, read from the raw unfolded value. A display-name encoded-word is decoded for display only, and an encoded addr-spec is a refusal. A second From is a refusal. A group in From is a refusal. A group in a recipient header still contributes its mailboxes. Reply-To, Sender, Return-Path, Errors-To, Disposition-Notification-To, Return-Receipt-To, Delivery-Receipt-To, Mail-Followup-To and Mail-Reply-To must stay clear of a deny-file identity (address, or a mailbox at a domain) and of a domain named by the pack-law file. Sender, Errors-To and Return-Path, when present, must be the identity under the same rule as From. An empty Return-Path is not that address. Any Resent-* header is a refusal, and so is any Apparently-To. To, Cc and Bcc are refused when the mailbox is an address line in the deny file, and when the domain or a parent of it is named by the deny file or by a deny-domain line in the pack-law file. An address line is that mailbox, not the whole domain. It also covers that local part, one subaddress separator, and anything after the separator. The separators are the subaddress line, or +- when the line is omitted. ., @, % and ! are not separators. An address rule is also compared by skeleton: NFD, combining marks removed, case folded twice, combining marks removed again, then NFC, with every ., _, -, + and = removed and nothing cut. The second fold turns capital sharp s into ss. The second removal drops the dot that case folding İ produces, so İ and i are one skeleton, and a mark cannot compose with the letter before it and escape the prefix. abc followed by U+0301 and x is denied by abc, and Lithuanian i + U+0307 + U+0300 is the same skeleton as ì. A recipient whose skeleton starts with the rule’s skeleton, at the rule’s domain or one in the same alias family, is refused, and so is one the folding below refuses. This over-blocks on purpose: a rule for john also refuses johnny and jo.hn-x, so a provider that ignores a character no family lists cannot deliver a denied mailbox. An address rule whose skeleton is empty refuses the deny file. The error names the line and not the address. A deny file that does not load refuses every send. A skeleton shorter than three characters still matches that mailbox, its subaddresses and its alias family, and does not use the prefix, so jo denies jo and jo+tag and not john. A provider alias family folds both the rule and the recipient, and adds its separators to the subaddress line on its domains. gmail.com and googlemail.com are one domain, dots in the local part are ignored, and + is a separator. On fastmail.com, + is a separator and a mailbox at <local>@<user>.fastmail.com is <user>+<local>@fastmail.com. Every label in front of fastmail.com is the user, and a hyphen there is part of the user name. A punycode user label is decoded to Unicode and folded as a local part, so x@xn--bcher-kva.fastmail.com is bücher+x@fastmail.com. proton.me, protonmail.com, protonmail.ch and pm.me are one domain, and so are icloud.com, me.com and mac.com, each with +. outlook.*, hotmail.*, live.* and msn.* (every domain with that first label, whatever the country suffix) are separate domains, each with +. Exact and subaddress matching stay on the domain the rule names. The skeleton prefix still crosses that family, so a rule for john@outlook.com also denies john@hotmail.com, john@live.com, john@msn.com and john@outlook.de. That over-block is the prefix, not a shared mailbox. On yahoo.*, ymail.* and rocketmail.com, - is a separator, and an address rule is also cut at its first separator, so a rule for one disposable address <base>-<keyword> covers <base> and every keyword. An alias line adds a family to these: alias <domain>[,<domain>...] <separators> [flag...]. A domain may be <label>.*, every domain whose first label is <label>. The separators are checked as on the subaddress line, and the flags are shared (the domains, and the same subdomain of each, are one mailbox), dots (dots in the local part are ignored), subdomain (<local>@<user>.<domain> is <user><sep><local>@<domain>, <sep> being the line’s first separator, folded again so dots also reaches the user) and cut (the rule is also cut at its first separator). An alias line cannot remove a built-in family, and one that does not parse, or names one domain or flag twice, refuses the config. A domain rule on a domain of a shared family covers every domain of that family and their subdomains, so domain gmail.com also refuses googlemail.com, domain pm.me the other Proton domains, and domain icloud.com me.com and mac.com. deny-domain in the pack-law file does the same. A domain or deny-domain value has to be a hostname after IDNA. A star, a colon, a slash, an at-sign, whitespace, or anything else that is not a hostname refuses the file. So does a domain a recipient cannot have, such as an all-numeric one, in a domain, deny-domain or address value. The detail is the line number and not the value. A wildcard is not how to name subdomains: domain example.test already covers a.example.test. An address value with a star, or with a character a parsed recipient cannot carry, is refused the same way. From is not matched this way. A domain literal (an address at @[ ... ]) in a watched header is a refusal. So is a domain that is not a hostname after IDNA, such as one with an underscore, a ! or a +, a label that starts or ends with a hyphen, a label over 63 characters or a name over 253: no rule could name it. An all-numeric domain is a refusal, judged after IDNA. That includes a dotted quad, a shorter form inetaton accepts, a hex label (0x) and a label with a leading zero. U+3002 or U+FF61 between the numbers is still that address. A source route (<@host:mailbox>) is a refusal. @, % or ! in a local part is a refusal, quoted or not, including a backslash escape inside the quotes. A local part that NFKC changes into ASCII punctuation, such as a fullwidth @, %, ! or +, is a refusal. A local part that contains a default ignorable code point, a noncharacter, an unassigned or private-use code point, a format or control character, or a space separator is a refusal. A zero-width space or a braille blank is still removed before the address is compared. A recipient domain, or a domain on a header checked against the deny file, is refused when one label mixes Latin with Cyrillic or Greek after NFKC and IDNA.

A deny-file domain compares a fullwidth spelling and the punycode form as the same domain. A homoglyph that NFKC and IDNA leave alone does not, and a label wholly in Cyrillic or Greek is not mapped to the Latin it looks like. Case is folded as the address skeleton folds it, so ß is ss and ı and İ are i, and a character a reader skips is removed before that comparison. From is not compared that way. handle, term and pair may share the deny file and are ignored here. A pack-law line that would allow a domain refuses the file. The detail of deny-file-invalid and pack-law-invalid is the line number, not the value. A message over 1 MiB, a control character in the header block, U+0085, U+2028 or U+2029 in that block, a header line over 998 octets, a header block that mixes CRLF and a bare LF, or a config that cannot be read is a refusal.

Code

When

from-missing

no From

duplicate-from

more than one From

from-not-identity

From is not the configured identity

from-unparsed

From does not parse as one mailbox

from-group

From uses group syntax

encoded-word

an encoded-word in a watched header did not decode

reply-to-denied

Reply-To names a deny-file identity

reply-to-unparsed

Reply-To does not parse

sender-denied

Sender names a deny-file identity

sender-unparsed

Sender does not parse

sender-not-identity

Sender is present and is not the identity

return-path-denied

Return-Path names a deny-file identity

return-path-unparsed

Return-Path does not parse

return-path-not-identity

Return-Path is present and is not the identity

errors-to-denied

Errors-To names a deny-file identity

errors-to-unparsed

Errors-To does not parse

errors-to-not-identity

Errors-To is present and is not the identity

disposition-notification-to-denied

Disposition-Notification-To names a deny-file identity

disposition-notification-to-unparsed

Disposition-Notification-To does not parse

return-receipt-to-denied

Return-Receipt-To names a deny-file identity

return-receipt-to-unparsed

Return-Receipt-To does not parse

delivery-receipt-to-denied

Delivery-Receipt-To names a deny-file identity

delivery-receipt-to-unparsed

Delivery-Receipt-To does not parse

mail-followup-to-denied

Mail-Followup-To names a deny-file identity

mail-followup-to-unparsed

Mail-Followup-To does not parse

mail-reply-to-denied

Mail-Reply-To names a deny-file identity

mail-reply-to-unparsed

Mail-Reply-To does not parse

apparently-to

an Apparently-To header is present

resent-header

a Resent-* header is present

mixed-script

a label mixes Latin with Cyrillic or Greek

recipient-address-denied

To, Cc or Bcc names a deny-file address

recipient-domain-denied

To, Cc or Bcc is at a denied domain

recipient-unparsed

To, Cc or Bcc does not parse

argv-recipient-denied

an argv recipient is a denied address or domain

argv-recipient-unparsed

an argv recipient does not parse

header-control

a control, or U+0085, U+2028 or U+2029

header-line-ending

the header block mixes CRLF and a bare LF

header-line-too-long

a header line is over 998 octets

message-too-large

the message is over 1 MiB

message-invalid

the bytes are not a header block

config-unreadable

the config path cannot be read

config-invalid

the config is missing a key or names an unknown one

identity-invalid

the configured identity is not one mailbox

deny-file-unreadable

the deny file cannot be read

deny-file-invalid

a deny-file line is not a known kind, a domain is not a hostname, or an address skeleton is empty. The detail is the line number

pack-law-unreadable

the pack-law file cannot be read

pack-law-invalid

a pack-law line is not deny-domain, or the domain is not a hostname. The detail is the line number

Environment the seat reads#

These are not read by this binary. They are how ljos finds it.

Variable

Meaning

POLICYD_BIN

absolute path to this binary

POLICYD_REQUIRED

if set to 1, a missing binary is a deny

Exit statuses#

Code

Meaning

0

allow, check-mail with nothing to refuse, or exec of a process that exited 0

2

deny, a check-mail refusal, missing verb, or empty usage

127

exec could not spawn the program

other

exec of a process that exited with that code

What this crate is#

  • Remember and Prefer stay in packset.

  • A check is the argv verdict. Pack rules stay in packset.

  • ljos policy prints the line and composes this verdict with pack rules.