anumati अनुमति
for AI agents

anumati

अनुमति · “permission”

Let AI agents ask for root commands, and run only the ones you approve.

v1.1.1 latest3 releases5 decisions recordedMIT license

The approval window: Claude Code asks to run three commands to install Docker, each with its reason and a checkbox, and a password field with Deny and Run 3 selected

Anumati (अनुमति) is Sanskrit for "permission, consent": nothing runs as root without yours.

An AI coding agent (Claude Code, Codex, Aider, Gemini CLI, anything that can run a shell command or speak MCP) regularly needs something done as root: install a package, enable a service, edit a file in /etc. The usual answer is to copy sudo commands between the agent and a terminal. anumati replaces that: the agent sends the commands, each with a reason; a window shows them on your desktop; you untick what you don't want, type your password once, and the ticked commands run. The agent gets every command's output and exit code back.

#Features

#Install

Requirements: Linux with a Wayland desktop (sway, Hyprland, GNOME, KDE…) and systemd's user manager, sudo, Python 3.11+, PyGObject, GTK 4, libadwaita, and libnotify (notify-send).

git clone https://github.com/Theyashsawarkar/anumati ~/.local/share/anumati
~/.local/share/anumati/install.sh          # asks for sudo once
anumati --why "Test" --cmd "id"            # the window opens; approve it

install.sh installs everything root-owned (/usr/local/bin/anumati, /usr/local/lib/anumati/), so an agent running as your user can't change what the window shows or what it runs. On Arch it installs the dependencies too; elsewhere install them first (Debian/Ubuntu: python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 libnotify-bin; Fedora: python3-gobject gtk4 libadwaita libnotify).

~/.local/share/anumati/install.sh --check       # what differs from this clone
git -C ~/.local/share/anumati pull && ~/.local/share/anumati/install.sh   # update
~/.local/share/anumati/install.sh --uninstall

It used to be called vayu-elevate; the installer keeps that name as a link to anumati, so agents and notes that still use it keep working.

#What you see

A window opens with everything the agent asked for: each command exactly as it will run, its reason, and a checkbox. Untick anything you don't want, type your password, press Run (or Enter). The ticked commands run in order, stopping at the first failure, and each card shows its result. If they all succeed the window closes by itself; if one fails it stays open with the error.

The window after an approved run: each command shows a tick and its duration, and Done: 3 succeeded. Closing…

KeyAction
Enter (password field)Run the selected commands as root
SpaceToggle the focused command
EscCancel the root request (deny everything; closes the window after a run)
Enter (after a failed run)Close the window

Closing the window, Deny or Esc means nothing runs. Three wrong passwords end the request. A request nobody answers expires after 10 minutes.

#For agents: sending a request

Send a JSON request on stdin. It blocks until the user answers (up to 10 minutes), then prints a JSON result.

anumati <<'EOF'
{
  "requester": "Claude Code",
  "reason": "Install Docker and start it on demand, as you asked.",
  "commands": [
    {"cmd": "pacman -S --needed --noconfirm docker", "why": "Install Docker"},
    {"cmd": "systemctl enable --now docker.socket", "why": "Start Docker when something connects to it"}
  ]
}
EOF

One-off form: anumati --requester "Claude Code" --why "Reload udev rules" --cmd "udevadm control --reload" (repeat --why/--cmd pairs for more). anumati --schema prints the request and result JSON Schema.

Rules for the commands:

Ask for exactly what's needed, explain each command honestly, don't re-send a denied request unless the user asks, and treat a declined command as the user's answer.

#For agents: reading the result

{
  "status": "completed",
  "message": "Ran the approved commands.",
  "approved": [0, 1],
  "declined": [],
  "results": [
    {"index": 0, "command": "pacman -S --needed --noconfirm docker", "why": "Install Docker",
     "decision": "approved", "state": "ok", "exit_code": 0, "stdout": "...", "stderr": "",
     "duration": 14.2, "timed_out": false, "truncated": false}
  ]
}
statusMeaning
completedAt least one command was approved and they ran: check each result's state
deniedThe user pressed Deny, Esc or closed the window; nothing ran
timeoutNo answer within 10 minutes; nothing ran
auth_failedWrong password three times; nothing ran
errorBad request or the tool itself failed; see message
stateMeaning
okRan, exit code 0
failedRan, non-zero exit code
skippedApproved, but an earlier command failed
declinedThe user unticked it
not_runThe request was denied, timed out or failed before running

Output is capped at 512 KB per stream (truncated: true when cut).

Exit codeMeaning
0Every command was approved and succeeded
1An approved command failed
2Bad request or tool error
3Nothing ran (denied, timeout, wrong password)
4Some commands were declined; the approved ones succeeded

#For agents: over MCP

anumati --mcp is an MCP server (stdio) with one tool, request_root_commands, taking the same request object and returning the result as text and structuredContent.

claude mcp add --scope user anumati -- anumati --mcp      # Claude Code
{
  "mcpServers": {
    "anumati": {"command": "/usr/local/bin/anumati", "args": ["--mcp"]}
  }
}

The call blocks until the user answers, up to 10 minutes. If your client times out tool calls sooner, raise its limit (Claude Code: the MCP_TOOL_TIMEOUT environment variable, in milliseconds).

#Telling your agent about it

Add a few lines to the agent's global instructions (Claude Code: ~/.claude/CLAUDE.md; Codex: ~/.codex/AGENTS.md; Gemini CLI: ~/.gemini/GEMINI.md):

- Commands that need root: don't ask me to paste sudo commands. Send them to
  `anumati` (JSON on stdin: requester, reason, commands: [{cmd, why}];
  see `anumati --help`). I approve them in a window; you get the results.
  Respect declined commands; never retry a denied request unasked.

#What's recorded

Every command that ran as root, with its exit code, goes to /var/log/anumati.log: root-only, so the agent can't edit it. Every request, denied ones included, goes to your own ~/.local/state/anumati/requests.jsonl.

sudo tail -n 20 /var/log/anumati.log | jq .
tail -n 5 ~/.local/state/anumati/requests.jsonl | jq .

#Security

anumati is a consent gate: nothing runs as root unless you see it and type your password.

GuaranteeHow
The agent never sees your passwordIt's typed into the approval window: a separate process the agent didn't start (a transient systemd user service, with a clean environment) and can't inspect (PR_SET_DUMPABLE=0).
What runs is what you sawThe window and the root-side runner are root-owned files; the window holds the request in memory once shown; Python runs isolated (-I); your GTK styling can't hide part of it (the window uses root-owned XDG dirs); invisible or direction-changing characters are refused.
No leftover accesssudo runs with -k both times: no cached credentials for anything else.

What it can't do: protect you from a program that is actively malicious and already running as your user. Such a program could draw a lookalike window to collect your password, or tamper with the systemd user manager the window starts from; that's true of every password prompt on a Linux desktop, polkit's included. Use anumati with agents you trust to ask honestly, and read each command before you approve it. Report security problems privately: SECURITY.md.

#How it works

  1. anumati rejects bad requests (no why, more than 50 commands, hidden characters), writes the request to $XDG_RUNTIME_DIR/anumati/<id>/ (mode 0700) and starts the window with systemd-run --user and env -i: a transient service, not a child of the agent.
  2. The window (lib/dialog.py, GTK 4 / libadwaita) reads the request once into memory and shows it. You untick, type your password, press Run.
  3. It checks the password with sudo -S -k true, then starts sudo -S -k -- python3 -I runner.py, writing the password and the approved commands to its stdin. Nothing is written to disk.
  4. The runner (lib/runner.py) runs each command with bash -c from /, no stdin, a minimal environment, 30 minutes max each, stopping at the first failure, and appends each to /var/log/anumati.log.
  5. The window writes result.json; anumati prints it and exits.

#Troubleshooting

#Contributing

Bug reports, ideas and pull requests are welcome: see CONTRIBUTING.md, and CHANGELOG.md for what changed in each release. Changes to what runs as root get extra scrutiny; security reports go privately (SECURITY.md).

anumati comes from Vayu, a keyboard-driven Wayland desktop, and works on any Linux desktop on its own.

#License

MIT