cli reference

Complete reference for the whyno command-line interface. Written for sysadmins and operators — no Rust or source code knowledge required.

Synopsis

whyno <subject> <operation> <path> [flags]
whyno <subject> chmod <path> [--new-mode MODE]
whyno <subject> chown-uid --new-uid UID <path>
whyno <subject> chown-gid --new-gid GID <path>
whyno <subject> setxattr --xattr-key KEY <path>
whyno caps <action>
whyno schema
whyno --help | --version

Quick Start

# Why can't nginx read this log file?
whyno nginx read /var/log/app/current.log

# Why can't UID 33 write to /tmp/upload?
whyno uid:33 write /tmp/upload

# Why can't PID 1234 execute this binary?
whyno pid:1234 execute /usr/bin/app

# Why can't the postgres service read a TLS key?
whyno svc:postgres read /etc/ssl/private/server.key

# JSON output for CI/scripting
whyno nginx read /var/log/app.log --json

# Full debug trace
whyno nginx read /var/log/app.log --explain

# Why can't a non-owner chmod this file?
whyno nginx chmod /var/log/app.log

# Can uid 33 chown this directory?
whyno uid:33 chown-uid --new-uid 33 /var/www/html

# Can uid 33 set a trusted xattr?
whyno uid:33 setxattr --xattr-key trusted.foo /data/file

Subjects

The first argument identifies who is trying to perform the operation. Four input formats are supported.

Username

Look up a local user via /etc/passwd and /etc/group.

whyno nginx read /path           # bare username (auto-detected)
whyno user:nginx read /path      # explicit prefix

Resolves UID, primary GID, and all supplementary groups. If the username isn't found in /etc/passwd, whyno exits with:

User 'nginx' not found in /etc/passwd.
If using LDAP/SSSD, pass uid:33 instead.

UID

Look up a numeric user ID.

whyno 33 read /path              # bare number (auto-detected as UID)
whyno uid:33 read /path          # explicit prefix

Bare numbers are always interpreted as UIDs, not PIDs. Use pid: for process IDs.

PID

Resolve the effective identity of a running process from /proc/<pid>/status.

whyno pid:1234 read /path

Reads the effective UID, GID, and supplementary groups of the process. Useful when a service runs as a non-obvious user or has dropped privileges. Fails if the process doesn't exist or /proc/<pid>/status is unreadable (e.g., hidepid=2).

Service name

Resolve a systemd service to its running process.

whyno svc:postgres read /path
whyno svc:nginx read /path

Runs systemctl show -p MainPID --value <name> to get the PID, then resolves via /proc/<pid>/status. Fails if:

  • systemctl is not available
  • The service is not running (MainPID=0)
  • The PID can't be resolved

Operations

The second argument specifies what action is being attempted. Case-insensitive.

Operation Permission needed Checked on Typical syscalls
read r Target file/dir open(O_RDONLY), readdir
write w Target file/dir open(O_WRONLY), truncate
execute x Target file/dir execve, directory traversal
delete w+x Parent directory unlink, rmdir
create w+x Parent directory open(O_CREAT), mkdir
stat Traverse only Ancestors only stat, lstat — "can I see this exists?"
chmod Owner or CAP_FOWNER Target file/dir chmod
chown-uid CAP_CHOWN Target file/dir chown (UID change)
chown-gid Owner-in-group or CAP_CHOWN Target file/dir chown (GID change)
setxattr Namespace-dependent (see below) Target file/dir setxattr

Note: chmod, chown-uid, chown-gid, and setxattr are metadata operations. They add a dedicated Metadata permission layer that checks ownership and capability requirements instead of DAC mode bits. The standard DAC/ACL layers pass through for metadata operations.

Note: delete and create check the parent directory, not the target itself. This matches kernel behavior — deleting /var/log/app.log requires w+x on /var/log/.

Note on setxattr namespaces: The --xattr-key prefix determines the xattr namespace and required capability: user.* and system.posix_acl_* require file ownership or CAP_FOWNER. trusted.* and security.* require CAP_SYS_ADMIN.

Path

The third argument is the target filesystem path. Must be absolute.

whyno walks every ancestor from / to the target, checking permissions at each level. This catches the common case where a deep ancestor like /var/log/app/ is missing +x for other users.

Flags

Flag Description
--json Output structured JSON instead of the human-readable checklist. Versioned schema ("version": 1). Designed for CI pipelines and scripting.
--explain Verbose mode — shows the full resolution chain: how the subject was resolved, raw stat() output per ancestor, ACL entries, mount options, and flag bits.
--no-color Disable ANSI color codes. Also triggered automatically by the NO_COLOR environment variable or when stdout is not a terminal.
--with-cap <CAP> Inject a Linux capability for hypothetical queries (e.g. --with-cap CAP_DAC_OVERRIDE). Repeatable. Overrides any gathered CapEff value. Recognised names: CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, CAP_FOWNER, CAP_LINUX_IMMUTABLE.
--self-test Cross-check whyno's result against the kernel. When the subject is the calling user's effective UID and the kernel is >= 5.8, calls faccessat2(AT_EACCESS) on the target path with the appropriate access flags and compares the kernel's boolean answer to whyno's own CheckReport::is_allowed() result. Any mismatch is printed to stderr.
--help Show help text with usage examples.
--version Print version and exit.
--new-mode <MODE> Target mode bits for the chmod operation (octal, e.g. 644 or 0755). Parsed as octal with or without a leading 0.
--new-uid <UID> Target UID for the chown-uid operation.
--new-gid <GID> Target GID for the chown-gid operation. If the subject is the file owner and a member of the target group, the operation is allowed without CAP_CHOWN.
--xattr-key <KEY> Extended attribute key for the setxattr operation (e.g. user.custom, security.selinux, trusted.foo). The key prefix determines the xattr namespace and required capability. Required when using setxattr.

--json and --explain are mutually exclusive. Specifying both produces an error.

Output Modes

Default: Checklist

One line per permission layer. Color-coded: green [PASS], red [FAIL], yellow [SKIP].

whyno nginx read /var/log/app/current.log

  Subject: uid=33, gid=33, groups=[33]
  Operation: read
  Target: /var/log/app/current.log

  [PASS] Mount options      — rw on /var (ext4)
  [PASS] Filesystem flags   — no immutable/append-only
  [FAIL] Path traversal     — /var/log/app: o-x (other has no execute)
         Fix: chmod o+x /var/log/app  [impact: 3/6]
  [FAIL] DAC permissions    — mode 0640 owner=root group=root, nginx is other
         Fix: setfacl -m u:nginx:r /var/log/app/current.log  [impact: 1/6]
  [SKIP] POSIX ACLs         — no ACL on target (would pass after DAC fix)
  [PASS] Metadata           — not a metadata operation
  [SKIP] SELinux            — SELinux state not gathered
  [SKIP] AppArmor           — AppArmor state not gathered

For metadata operations (chmod, chown-uid, chown-gid, setxattr), the Metadata layer shows the ownership/capability check result instead:

whyno uid:1001 chmod /etc/passwd
  Subject: uid=1001, gid=1001, groups=[1001]
  Operation: chmod
  Target: /etc/passwd
  [PASS] Mount options      — rw on / (ext4)
  [PASS] Filesystem flags   — no immutable/append-only
  [PASS] Path traversal     — all ancestors have +x
  [PASS] DAC permissions    — not a metadata operation
  [PASS] POSIX ACLs         — not a metadata operation
  [FAIL] Metadata           — subject (uid=1001) is not the file owner (uid=0) and lacks CAP_FOWNER
         Fix: setcap cap_fowner+ep /path/to/binary  [impact: 5/6]
  [SKIP] SELinux            — SELinux state not gathered
  [SKIP] AppArmor           — AppArmor state not gathered

Failed layers include a fix suggestion with an impact score (1 = most targeted, 6 = broadest blast radius).

JSON mode (--json)

Stable, versioned schema for automation:

{
  "version": 1,
  "subject": { "uid": 33, "gid": 33, "groups": [33] },
  "operation": "read",
  "target": "/var/log/app/current.log",
  "result": "denied",
  "layers": [
    { "name": "mount", "result": "pass", "detail": "rw on /var (ext4)" },
    { "name": "fsflags", "result": "pass", "detail": "no immutable/append-only" },
    { "name": "traversal", "result": "fail", "detail": "/var/log/app: o-x" },
    { "name": "dac", "result": "fail", "detail": "mode 0640 owner=root group=root" },
    { "name": "acl", "result": "degraded", "detail": "no ACL on target" },
    { "name": "metadata", "result": "pass", "detail": "not a metadata operation" },
    { "name": "selinux", "result": "degraded", "detail": "SELinux state not gathered" },
    { "name": "apparmor", "result": "degraded", "detail": "AppArmor state not gathered" }
  ],
  "fixes": [
    { "command": "chmod o+x /var/log/app", "impact": 3, "layer": "traversal", "description": "grant other execute on /var/log/app" },
    { "command": "setfacl -m u:nginx:r /var/log/app/current.log", "impact": 1, "layer": "dac", "description": "grant nginx read via ACL" }
  ],
  "warnings": [],
  "degraded": []
}

JSON goes to stdout. Warnings and hints go to stderr. Pipe safely:

whyno nginx read /path --json 2>/dev/null | jq '.fixes[].command'

Explain mode (--explain)

Full debug output showing:

  • How the subject was resolved (source file, parsed fields)
  • Raw stat() results for every path ancestor
  • ACL entries and mask values
  • Mount options and filesystem type
  • Inode flag bits

Use this when the default checklist doesn't give enough context to understand the failure.

Permission Layers

whyno checks 8 permission layers in order (6 core + 2 MAC). All layers always run — no short-circuiting. This ensures every blocker is visible, not just the first one.

# Layer What it checks Bypassed by root?
1 Mount options ro (read-only), noexec, nosuid on the target's filesystem ❌ No
2 Filesystem flags immutable and append-only inode flags (chattr +i, chattr +a) ❌ No
3 Path traversal +x (execute/search) on every ancestor directory from / to the target ✅ Yes (via CAP_DAC_READ_SEARCH)
4 DAC permissions Traditional owner/group/other rwx mode bits ✅ Yes (via CAP_DAC_OVERRIDE), except: root cannot execute a file with no x bit set anywhere
5 POSIX ACLs Named user/group ACL entries with mask application ✅ Yes (via CAP_DAC_OVERRIDE)
6 Metadata Ownership and capability checks for chmod/chown/setxattr operations Depends on operation (see below)
7 SELinux Mandatory access control via in-kernel AVC (--features selinux) ❌ No
8 AppArmor Profile-based mandatory access control (--features apparmor) ❌ No

Metadata layer details

The Metadata layer only activates for metadata operations (chmod, chown-uid, chown-gid, setxattr). For standard I/O operations (read, write, execute, etc.), it reports [PASS] with "not a metadata operation".

Operation Pass condition Capability bypass
chmod Subject is file owner CAP_FOWNER
chown-uid Never (always requires capability) CAP_CHOWN
chown-gid Subject is owner and member of target group CAP_CHOWN
setxattr (user.*, system.posix_acl_*) Subject is file owner CAP_FOWNER
setxattr (trusted.*, security.*) Never (always requires capability) CAP_SYS_ADMIN

Warning: When CAP_FOWNER is used for chmod and the caller lacks CAP_FSETID and is not in the file's group, the kernel will strip the setgid bit. whyno reports this as a warning.

Layer results

  • [PASS] — This layer allows the operation.
  • [FAIL] — This layer blocks the operation. A fix suggestion is shown.
  • [SKIP] — This layer couldn't be checked (insufficient privilege, missing data, or MAC layer not compiled in — rebuild with --features selinux / --features apparmor). Never treated as a pass. Also shown for chown-gid when --new-gid is not provided.

Optional MAC layers

SELinux and AppArmor checks are compiled in via feature flags (--features selinux, --features apparmor). Systemd sandboxing, user namespaces, and seccomp filters are not checked.

The [SKIP] detail depends on the build and runtime environment. When MAC state was not gathered (e.g., the system lacks SELinux or AppArmor):

[SKIP] SELinux            — SELinux state not gathered
[SKIP] AppArmor           — AppArmor state not gathered

When a MAC layer is active on the system but the feature is not compiled in:

[SKIP] SELinux            — SELinux — not compiled in (rebuild with --features selinux)
[SKIP] AppArmor           — AppArmor — not compiled in (rebuild with --features apparmor)

Additionally, whyno prints a runtime note to stderr when it detects an active MAC system without the corresponding feature enabled:

Note: SELinux is active — rebuild with --features selinux to include MAC checks
Note: AppArmor is active — rebuild with --features apparmor to include MAC checks

Fix Suggestions

Every [FAIL] layer includes a repair command. Fixes are ranked by security impact — lowest score first (least privilege).

Impact Fix type Blast radius Example
1 ACL grant (specific user) One user gains access setfacl -m u:nginx:r file
2 Group change / ACL group All group members chown :www-data file
3 Permission bit (group) All group members chmod g+r file
4 Permission bit (other) Everyone on the system chmod o+r file
5 Remove filesystem flag Removes a protection chattr -i file
6 Remount filesystem Entire filesystem mount -o remount,rw /var
  • The primary suggestion is always the lowest-impact fix.
  • Higher-impact alternatives are shown in --explain mode.
  • Fixes with impact ≥ 5 include a ⚠ warning with a blast radius description.
  • chmod 777 and o+rwx are never suggested.

Multi-layer fix plans

When multiple layers block, fixes are ordered outermost layer first (mount → fs flags → traversal → DAC → ACL). The fix engine simulates each fix and drops any that become redundant after an earlier fix resolves the issue.

Exit Codes

Code Meaning When
0 Allowed All layers pass — the operation would succeed
1 Denied At least one layer blocks the operation
2 Error whyno itself couldn't complete the check (bad args, missing /proc, etc.)

Same codes in all output modes (checklist, JSON, explain). Degraded/skipped layers do not force a non-zero exit.

Useful in scripts:

if whyno nginx read /var/log/app.log --json 2>/dev/null | jq -e '.result == "allowed"' > /dev/null; then
  echo "Access OK"
else
  echo "Access blocked — run whyno for details"
fi

Capability Management

whyno can grant itself CAP_DAC_READ_SEARCH for full coverage without running as root on every invocation.

sudo whyno caps install      # Grant capability (one-time, requires root)
sudo whyno caps uninstall    # Remove capability
whyno caps check             # Show current capability status

How it works

caps install writes a security.capability extended attribute directly on the whyno binary using raw setxattr(). No libcap, setcap, or any external tool needed.

Requirements

  • Root for install and uninstall (the kernel requires it to set security.* xattrs)
  • Filesystem must support extended attributes (ext4, xfs, btrfs ✅ — NFS, FAT, tmpfs ❌)
  • The binary must not be on a nosuid mount (capabilities are ignored on nosuid filesystems)

What CAP_DAC_READ_SEARCH provides

  • Read any file regardless of permissions
  • Traverse (+x) any directory
  • Read any extended attribute (ACLs, filesystem flags)

This means whyno can fully inspect paths that the running user can't normally access, so all layers report accurate results instead of [SKIP].

JSON Schema

whyno schema

Prints the JSON Schema for the --json output format to stdout. Useful for validating output in CI or generating client types in other languages. The schema is derived from the internal JsonReport struct at compile time via schemars.

Unprivileged Mode

Without elevated privileges, whyno still works — but with reduced coverage.

Check Works unprivileged? What happens when it can't
Mount options ✅ Always
Path traversal 🟡 Partial Stops at the first inaccessible ancestor — reports where, marks deeper checks [SKIP]
DAC permissions 🟡 Partial Needs traversal to reach the target first
POSIX ACLs 🟡 Partial Needs access to read xattrs on the target
Filesystem flags 🟡 Partial Needs open() on the target (read-only is sufficient)
Username/UID resolution ✅ Always
PID resolution ✅ Usually Fails on hidepid=1 or hidepid=2 systems
Service → PID ✅ Usually May fail in locked-down D-Bus configs

When running unprivileged, whyno prints a one-time hint:

⚠ Some checks were skipped due to insufficient privilege.
  Run: sudo whyno caps install   (one-time setup)
  Or:  sudo whyno ...            (per invocation)

Root / UID 0 Behavior

whyno does not short-circuit for root. All 8 layers are checked normally. The output explains why root can access something, not just that it can.

Special root handling in the DAC layer:

  • CAP_DAC_OVERRIDE bypasses all DAC denials except execute: root cannot execute a file where no x bit is set at all (mode 0644 blocks execve even for root).
  • Mount options and filesystem flags are never bypassed by root — a chattr +i file blocks root too.

Common Scenarios

"Permission denied" on a log file

whyno nginx read /var/log/myapp/error.log

Usually a path traversal problem (/var/log/myapp/ missing o+x) or a DAC problem (file is 0640 root:root).

Debugging a systemd service

whyno svc:myapp write /var/lib/myapp/data.db

Resolves the service's actual runtime identity — catches cases where the service runs as a different user than expected.

CI gate: verify deploy permissions

whyno deploy-user create /var/www/app/release.tar.gz --json
# Exit code 0 = deploy will succeed, 1 = will fail

Check if a cron job can execute a script

whyno uid:0 execute /opt/scripts/backup.sh

Even for root, this catches missing +x bits and noexec mounts.

Audit: can "other" read a sensitive file?

whyno uid:65534 read /etc/shadow
# uid 65534 = nobody — represents "any unprivileged user"

Environment Variables

Variable Effect
NO_COLOR When set (any value), disables ANSI color output. Equivalent to --no-color.

Build Targets

whyno ships as a statically linked musl binary for four Linux architectures:

Architecture Target triple
x86_64 x86_64-unknown-linux-musl
aarch64 (ARM 64-bit) aarch64-unknown-linux-musl
armv7 (ARM 32-bit, hard-float) armv7-unknown-linux-musleabihf
riscv64 (RISC-V 64-bit) riscv64gc-unknown-linux-musl

All targets use +crt-static for fully static linking. Cross-compilation is handled via cross. Binaries are placed in dist/<target>/whyno.

Development: justfile Commands

The project uses a justfile for common build and test recipes:

Recipe Description
just release Build all four musl static targets into dist/<target>/whyno
just release-x86_64 Build x86_64-unknown-linux-musl
just release-aarch64 Build aarch64-unknown-linux-musl
just release-armv7 Build armv7-unknown-linux-musleabihf
just release-riscv64 Build riscv64gc-unknown-linux-musl
just test-cross Run cross test --workspace for all four targets sequentially
just test-selinux Run SELinux integration tests on Fedora VM (rsync + SSH)
just test-apparmor Run AppArmor integration tests on Ubuntu VM (rsync + SSH)
just test-live Run all live kernel integration tests (SELinux + AppArmor)
just proxmox-setup Verify SSH connectivity to both test VMs
just clean Remove the dist/ directory

Errors and Troubleshooting

Error Cause Fix
User 'X' not found in /etc/passwd Username doesn't exist locally Use uid:N for LDAP/SSSD users
invalid uid: X Non-numeric value after uid: prefix Check the UID value
invalid pid: X Non-numeric value after pid: prefix Check the PID value
service is not running (MainPID=0) The systemd service is stopped Start the service or use user: instead
systemctl not available Not a systemd system or systemctl not in PATH Use pid: or user: instead
unknown operation: X Invalid operation name Use: read, write, execute, delete, create, stat, chmod, chown-uid, chown-gid, setxattr
--json and --explain are mutually exclusive Both flags specified Pick one
unknown capability: X Unrecognised name passed to --with-cap Use a recognised name: CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, CAP_FOWNER, CAP_LINUX_IMMUTABLE
missing --xattr-key for setxattr operation setxattr used without --xattr-key Add --xattr-key <KEY> (e.g. --xattr-key user.custom)
invalid xattr key: X Unrecognised xattr namespace prefix Use a recognised prefix: user., trusted., security., system.posix_acl_
invalid mode: X Non-octal value passed to --new-mode Use an octal string (e.g. 644 or 0755)
Cannot set capabilities: filesystem does not support extended attributes caps install on unsupported FS (NFS, FAT, tmpfs) Move the binary to ext4/xfs/btrfs
setxattr failed: EPERM caps install without root Run with sudo