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 | --versionQuick 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/fileSubjects
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 prefixResolves 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 prefixBare 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 /pathReads 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 /pathRuns systemctl show -p MainPID --value <name> to get the PID, then resolves via /proc/<pid>/status. Fails if:
systemctlis 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 gatheredFor 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 gatheredFailed 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 forchown-gidwhen--new-gidis 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 gatheredWhen 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 checksFix 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
--explainmode. - Fixes with impact ≥ 5 include a ⚠ warning with a blast radius description.
chmod 777ando+rwxare 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"
fiCapability 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 statusHow 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
installanduninstall(the kernel requires it to setsecurity.*xattrs) - Filesystem must support extended attributes (ext4, xfs, btrfs ✅ — NFS, FAT, tmpfs ❌)
- The binary must not be on a
nosuidmount (capabilities are ignored onnosuidfilesystems)
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 schemaPrints 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_OVERRIDEbypasses all DAC denials except execute: root cannot execute a file where noxbit is set at all (mode0644blocksexecveeven for root).- Mount options and filesystem flags are never bypassed by root — a
chattr +ifile blocks root too.
Common Scenarios
"Permission denied" on a log file
whyno nginx read /var/log/myapp/error.logUsually 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.dbResolves 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 failCheck if a cron job can execute a script
whyno uid:0 execute /opt/scripts/backup.shEven 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 |