Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ once it reaches a published 0.1.0 release.

## [Unreleased]

### Added

- `--allow-root` lets mandible run as root; without it, the TUI, `--doctor`, and `--report` refuse before probing anything when effective uid is 0.

### Fixed

- `xtask sweep-diff` now reports positional-count gains and losses per tool, separately from flags and subcommands, so a positional turning into a flag (or vice versa) no longer passes as a clean sweep; the field-level report also names each positional added or removed instead of folding it into the flag list.
- The existence-fabrication oracle now attests a contiguous multi-word operand from a synopsis line's own slots (S-154's shape, `mknod`'s `MAJOR MINOR`), instead of only single tokens, so `mknod`, `accessdb`, `gdk-pixbuf-thumbnailer`, `systemd-sysusers` and `systemd-tmpfiles` no longer report a fabricated positional they genuinely document.

## [0.8.0] - 2026-09-14

### Changed
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,13 @@ contributed, and how much of the tool it understood. It turns "mandible is
wrong about tool X" into "the cobra grammar mishandles Y", which is a bug
someone can actually fix.

### Running as root

mandible refuses to start as root, printing the flag that lifts the refusal.
A probe like `fail2ban-client start --help` reaches a daemon socket, and
under root that is an action, not a question. Pass `--allow-root` to proceed
anyway; mandible never asks for or gains privileges on its own.

</details>

## Documentation
Expand Down
14 changes: 13 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,16 @@ allowlist below.
`COLUMNS` this policy sets cannot be matched; the scratch prefix is kept
short to make that rare.

10. **Refuse to start at all under uid 0.** The binary checks its effective
uid before the TUI, `--doctor`, or `--report` resolves a tool, and
before any probe spawns. Refusal names the flag that lifts it,
`--allow-root`.

A subcommand probe such as `fail2ban-client start --help` reaches a
daemon socket, and under root that is an action rather than a question.
mandible never asks for or gains privileges itself, so this rule is
about the privilege the user already brought, not one mandible seeks.

**A convention-discovered node (§5.4's `<parent>-<sub>` children, named by a
file on `PATH`) adds no argv shape and no exemption.** It is probed as its
own binary's root `--help`, never as a subcommand word, so it needs no
Expand Down Expand Up @@ -1168,7 +1178,9 @@ Neither was a bad shape; both were a right shape sent to the wrong
program. A per-tool list of who may be probed would be §1's forbidden
knowledge wearing a safety label, so rule 1a requires evidence instead.

**Implemented in.** `mandible-extract/src/exec/`.
**Implemented in.** `mandible-extract/src/exec/`. Rule 10 is implemented in
`mandible/src/root_guard.rs`, ahead of the exec chokepoint, since it governs
whether mandible starts at all rather than one probe's argv.

---
---
Expand Down
12 changes: 8 additions & 4 deletions docs/instruments.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ own lifecycle rules, never because the tool it names became inconvenient.
## SweepDiff (`xtask sweep-diff`)

SweepDiff compares two rendered coverage scoreboards and reports which
tools gained or lost flags, which gained or lost subcommands, and which
changed parse status, without netting gains against losses. A subcommand
gain is named, never scored: it can be a real recovery or an invented row,
and only a human reading the rendered screen can tell which. It answers
tools gained or lost flags, which gained or lost subcommands, which gained
or lost positionals, and which changed parse status, without netting gains
against losses. A subcommand or positional gain is named, never scored: it
can be a real recovery or an invented row, and only a human reading the
rendered screen can tell which. The positional column is read off each
scoreboard's `#fp2` entity ids (S-154), so it cannot be derived from a V1
`#fp` scoreboard or a pair missing a fingerprint footer entirely, reported
honestly as unmeasured rather than as "no positional change". It answers
"which specific tools did this
change touch", the question the fleet-wide aggregate cannot answer
because a four-tool regression moves it by hundredths of a percent. It
Expand Down
4 changes: 3 additions & 1 deletion docs/shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -3100,7 +3100,9 @@ entry's `tools` field and nothing else. It does not get a new entry.
operand per bracket pair, the inner ones optional
(`bare_bracket_group_is_flat`, `mandible-extract/src/help_text/sections/
multiword.rs`). The sweep that admitted the rule could not see this,
because sweep-diff compares flags and subcommands and never positionals.
because sweep-diff compared flags and subcommands and never positionals at
the time; it now reports a positional-count gain/loss column and names
each positional added or removed per tool.
- fleet: `trailing-bracket-group-multiword-operand` reads 0 tools/0 findings
post-fix on a full-`PATH` sweep of 2323 tools, 2026-09-13, and is ratcheted
there. `nested-bracket-group-fused-operand`
Expand Down
1 change: 1 addition & 0 deletions mandible/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ mandible-tui.workspace = true
clap.workspace = true
clap_complete.workspace = true
anyhow.workspace = true
nix = { version = "0.29", default-features = false, features = ["user"] }
tracing.workspace = true
tracing-subscriber.workspace = true
crossterm.workspace = true
Expand Down
6 changes: 6 additions & 0 deletions mandible/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ pub struct Cli {
/// Mirrors `xtask audit`'s own `--dir` default.
#[arg(long, default_value = "audit")]
pub audit_dir: PathBuf,

/// Proceed when running as root (uid 0) instead of refusing. mandible
/// never asks for or gains privileges itself; this only lifts the
/// refusal on privilege the user already brought. See spec §6 rule 10.
#[arg(long)]
pub allow_root: bool,
}

impl Cli {
Expand Down
8 changes: 8 additions & 0 deletions mandible/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mod discovery;
mod doctor;
mod pipeline;
mod report;
mod root_guard;
mod shell_init;

use clap::{CommandFactory, Parser};
Expand Down Expand Up @@ -43,6 +44,13 @@ fn main() -> anyhow::Result<()> {
return Ok(());
}

// Before any probe spawns (spec §6 rule 10): the TUI, `--doctor`,
// `--report` and `--review` all pass through here first, and nothing
// above this point resolves or runs a tool.
if let Some(refusal) = root_guard::refusal(nix::unistd::Uid::effective(), cli.allow_root) {
anyhow::bail!(refusal);
}

if let Some(seed) = cli.review {
if !Sink::Stdout.is_tty() {
anyhow::bail!(
Expand Down
36 changes: 36 additions & 0 deletions mandible/src/root_guard.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//! Refuses to run as root before any probe spawns (spec §6 rule 10).
//! Checked once in `main`, before any tool resolves.

use nix::unistd::Uid;

/// One-line refusal `main` prints and exits on; names `--allow-root`.
pub const REFUSAL: &str = "mandible refuses to run as root (uid 0); pass --allow-root to proceed.";

pub fn refusal(uid: Uid, allow_root: bool) -> Option<&'static str> {
if uid.is_root() && !allow_root {
Some(REFUSAL)
} else {
None
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn root_without_the_flag_is_refused() {
assert_eq!(refusal(Uid::from_raw(0), false), Some(REFUSAL));
}

#[test]
fn root_with_the_flag_proceeds() {
assert_eq!(refusal(Uid::from_raw(0), true), None);
}

#[test]
fn a_normal_uid_proceeds_without_the_flag() {
assert_eq!(refusal(Uid::from_raw(1000), false), None);
assert_eq!(refusal(Uid::from_raw(1000), true), None);
}
}
76 changes: 76 additions & 0 deletions mandible/tests/root_refusal.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! Spec §6 rule 10: refuses before any probe when effective uid is 0.
//!
//! Proven against a real uid 0 via a user namespace (AGENTS.md's own
//! `unshare --user --map-root-user`), not a shim, per §3.5. Skips cleanly
//! where unprivileged user namespaces are unavailable, since that is an
//! environment limit, not a claim about the code.

use std::process::Command;

fn mandible() -> Command {
Command::new(env!("CARGO_BIN_EXE_mandible"))
}

fn user_namespaces_available() -> bool {
Command::new("unshare")
.args(["--user", "--map-root-user", "true"])
.status()
.map(|s| s.success())
.unwrap_or(false)
}

#[test]
fn root_is_refused_before_any_probe() {
if !user_namespaces_available() {
eprintln!("unshare --user --map-root-user unavailable; skipping");
return;
}
let out = Command::new("unshare")
.args(["--user", "--map-root-user"])
.arg(env!("CARGO_BIN_EXE_mandible"))
.args(["--doctor", "git"])
.output()
.expect("failed to run mandible under unshare");

assert!(!out.status.success(), "root must be refused");
assert_eq!(out.stdout, b"", "no probe output belongs on stdout");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--allow-root"),
"the refusal must name the flag: {stderr:?}"
);
}

#[test]
fn allow_root_lets_root_proceed() {
if !user_namespaces_available() {
eprintln!("unshare --user --map-root-user unavailable; skipping");
return;
}
let out = Command::new("unshare")
.args(["--user", "--map-root-user"])
.arg(env!("CARGO_BIN_EXE_mandible"))
.args(["--allow-root", "--doctor", "git"])
.output()
.expect("failed to run mandible under unshare");

let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("--allow-root"),
"the refusal must not fire: {stderr:?}"
);
}

/// A non-root uid never sees the refusal, regardless of the flag.
#[test]
fn non_root_never_refused() {
let out = mandible()
.args(["--doctor", "git"])
.output()
.expect("failed to run mandible");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!stderr.contains("--allow-root"),
"a non-root run must not print the root refusal: {stderr:?}"
);
}
9 changes: 9 additions & 0 deletions packaging/mandible.1
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,15 @@ eval "$(mandible \-\-shell\-init bash)"
.fi
.RE
.TP
.B \-\-allow\-root
Proceed when running as root (uid 0) instead of refusing.
.B mandible
refuses to start as root before probing anything, and names this flag in
the refusal.
.B mandible
never asks for or gains privileges itself; this only lifts the refusal on
privilege you already brought.
.TP
.B \-h\fR,\fB \-\-help
Print help and exit.
.TP
Expand Down
Loading
Loading