A CLI utility that acts as a PreToolUse Hook for Claude Code, providing intelligent auto-approval of safe Bash commands.
MMI parses Bash commands and automatically approves those that the user specifies as safe, eliminating the need for manual approval on every command. This significantly speeds up development workflows while maintaining security through a configurable deny/allowlist approach.
Important: Allowing an LLM to execute arbitrary Bash commands in a non-sandboxed environment is inherently unsafe. MMI may reduce that risk but cannot guarantee safety! Use at your own risk and always review your configuration carefully.
Note: Claude Code now offers a built-in Bash sandbox mode that restricts file system and network access. You can enable it in your Claude Code settings. MMI can be used alongside sandbox mode for additional control over which commands are auto-approved.
The name "Mother May I?" references the childhood game where permission must be granted before taking action.
This project was inspired by this post by Matt Rocklin.
brew install dgerlanc/tap/mmijust installOR
go build -o mmi
mv mmi /usr/local/bin/Pre-built binaries for Linux, macOS, and Windows are available on the Releases page.
- Install
mmi(see above) - Run
mmi initto create the configuration and set up the Claude Code hook - (Optional) Include an example config for your language stack (see Example Configurations)
The mmi init command automatically:
- Creates a default configuration file at
~/.config/mmi/config.toml - Configures Claude Code's
~/.claude/settings.jsonto use mmi as a PreToolUse hook
Running mmi init automatically configures Claude Code's ~/.claude/settings.json with the mmi hook. If you need to configure it manually, add this to your settings:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "mmi"
}
]
}
]
}
}mmi uses a TOML configuration file at ~/.config/mmi/config.toml. Generate the default config:
mmi initOr set a custom location via the MMI_CONFIG environment variable.
The config file has three main sections:
# Deny list - patterns always rejected (checked first)
[[deny.simple]]
name = "privilege escalation"
commands = ["sudo", "su", "doas"]
[[deny.regex]]
pattern = 'rm\s+(-[rRfF]+\s+)*/'
name = "rm root"
# Wrappers - prefixes stripped before checking core command
[[wrappers.simple]]
name = "env"
commands = ["env", "do"]
[[wrappers.command]]
command = "timeout"
flags = ["<arg>"]
# Commands - safe commands allowed to execute
[[commands.simple]]
name = "read-only"
commands = ["ls", "cat", "grep"]
[[commands.subcommand]]
command = "git"
subcommands = ["diff", "log", "status", "add"]
flags = ["-C <arg>"]
[[commands.regex]]
pattern = '^(true|false|exit(\s+\d+)?)$'
name = "shell builtin"Split your configuration across multiple files:
include = ["python.toml", "rust.toml"]To use different configurations for different projects, set the MMI_CONFIG environment variable to point to a different config directory.
Run as a hook - reads JSON from stdin, outputs approval JSON to stdout.
Create the configuration file and set up the Claude Code hook:
mmi init # Create config (if needed) and configure Claude Code
mmi init --force # Overwrite existing config and configure Claude Code
mmi init --config-only # Only create config.toml, skip Claude settings
mmi init --claude-settings /path/to/settings.json # Use custom settings pathBehavior:
- If the config file doesn't exist or
--forceis used, it creates/overwrites~/.config/mmi/config.toml - If the config file exists and
--forceis not set, it prints a notice but continues - Unless
--config-onlyis set, it always configures Claude Code's settings.json (if not already configured)
This allows you to reconfigure Claude Code hooks without needing to use --force, which would unnecessarily overwrite your config file.
The default config includes basic Unix utilities and shell builtins. For language-specific commands (Python, Node.js, Rust), copy an example config from examples/.
Validate configuration and display compiled patterns:
mmi validateGenerate shell completion scripts:
# Bash
mmi completion bash > /etc/bash_completion.d/mmi
# Zsh
mmi completion zsh > "${fpath[1]}/_mmi"
# Fish
mmi completion fish > ~/.config/fish/completions/mmi.fish
# PowerShell
mmi completion powershell > mmi.ps1| Flag | Description |
|---|---|
-v, --verbose |
Enable debug logging |
--dry-run |
Test command approval without JSON output |
--no-audit-log |
Disable audit logging |
mmi uses a three-layer approval model:
- Deny List - Patterns that are always rejected (checked first)
- Wrappers - Safe command prefixes that can wrap any approved command
- Safe Commands - Allowlisted commands that are safe to execute
When a command is submitted, mmi:
- Parses and splits command chains (handling
&&,||,|,;,&)- Unparseable commands (incomplete syntax, unclosed quotes) are rejected
- For each segment:
- Checks for dangerous patterns (command substitution
$()or backticks) - Checks deny list
- Strips safe wrappers
- Checks deny list again on core command
- Checks if core command matches safe patterns
- Checks for dangerous patterns (command substitution
- Approves only if ALL segments pass all checks
- Logs all segments to audit trail (all segments are evaluated even if earlier ones fail)
The default configuration is intentionally restrictive. Use example configs for language-specific setups.
- Privilege escalation:
sudo,su,doas - Dangerous patterns:
rm -rf /,chmod 777,dd of=/dev/,mkfs.*
timeout N- timeout wrappernice/nice -n N- process priorityenv- environment setupVAR=value- environment variable assignmentsdo- loop body prefix
| Category | Commands |
|---|---|
| Unix Utilities | ls, cat, head, tail, wc, find, grep, rg, file, which, pwd, du, df, curl, sort, uniq, cut, tr, awk, sed, xargs |
| File Ops | touch, make |
| Shell | echo, cd, true, false, exit, sleep |
Copy from examples/ to enable language-specific commands:
| Config | Enables |
|---|---|
python.toml |
pytest, python, ruff, uv, uvx, mypy, black, isort, pip, git subcommands |
node.toml |
npm, npx, node, yarn, pnpm, bun, eslint, prettier, tsc, git subcommands |
rust.toml |
cargo, rustup, maturin, rustc, rustfmt, git subcommands |
minimal.toml |
Basic read-only commands plus git read-only (status, log, diff, show, branch) |
strict.toml |
Read-only only, denies file modifications |
mmi logs all approval decisions to ~/.local/share/mmi/audit.log in JSON-lines format. Disable with --no-audit-log.
Example audit log entries
Approved command:
{
"version": 1,
"tool_use_id": "toolu_abc123",
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-15T10:30:00.5Z",
"duration_ms": 0.42,
"command": "git status",
"approved": true,
"segments": [
{
"command": "git status",
"approved": true,
"match": {
"type": "subcommand",
"name": "git"
}
}
],
"cwd": "/home/user/project"
}Rejected command (deny match):
{
"version": 1,
"tool_use_id": "toolu_def456",
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-01-15T10:30:05.1Z",
"duration_ms": 0.38,
"command": "rm -rf /",
"approved": false,
"segments": [
{
"command": "rm -rf /",
"approved": false,
"rejection": {
"code": "DENY_MATCH",
"name": "rm root",
"pattern": "rm\\s+(-[rRfF]+\\s+)*/"
}
}
],
"cwd": "/home/user/project"
}Audit log field reference
| Field | Description |
|---|---|
version |
Log format version (currently 1) |
tool_use_id |
Claude Code tool use identifier |
session_id |
Claude Code session identifier |
timestamp |
UTC timestamp with tenths of second precision |
duration_ms |
Processing time in milliseconds |
command |
The full command that was evaluated |
approved |
Whether the command was approved |
segments |
Array of individual command segments (for chained commands) |
cwd |
Working directory |
Segment fields:
| Field | Description |
|---|---|
match |
Present when approved; contains type, pattern, and name |
rejection |
Present when rejected; contains code and optionally name, pattern, detail |
mmi follows a fail-secure default:
- Deny patterns are checked first and override all approvals
- Unrecognized commands are automatically rejected
- Unparseable commands (incomplete syntax, unclosed quotes) are rejected
- Command substitution (
$(...)and backticks) is always rejected (except in quoted heredocs) - Command chains are only approved if ALL segments are safe
- All segments are evaluated and logged even if earlier segments fail
- Only explicitly allowlisted patterns are allowed
- Shell loops (
while,for) must be complete; their inner commands are extracted and validated individually
The examples/ directory contains ready-to-use configurations:
minimal.toml- Bare-bones for security-conscious userspython.toml- Python development (pytest, uv, ruff, mypy, etc.)node.toml- Node.js development (npm, yarn, pnpm, bun, etc.)rust.toml- Rust development (cargo, rustup, maturin, etc.)strict.toml- Read-only commands only
To use an example config:
# Replace default config with an example
cp examples/python.toml ~/.config/mmi/config.toml
# Or use includes to combine configs
echo 'include = ["python.toml"]' >> ~/.config/mmi/config.toml
cp examples/python.toml ~/.config/mmi/mmi outputs JSON decisions:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "timeout + pytest"
}
}If no configuration file exists at ~/.config/mmi/config.toml (or the path specified by MMI_CONFIG), mmi will reject all commands. This fail-secure behavior ensures that commands are never auto-approved without explicit configuration. Run mmi init to create a default configuration file.
Command substitution can execute arbitrary commands inside what appears to be a safe command. For example, echo $(rm -rf /) looks like an echo command but actually deletes files. mmi rejects both $(...) and backtick syntaxes for security.
Exception: Content inside quoted heredocs (single or double quoted delimiters) is treated as literal text and won't trigger rejection:
cat > file.go << 'EOF'
fmt.Printf(`template`) # Allowed - quoted heredoc
EOFUse mmi validate to see your compiled patterns, or use the --dry-run flag to test specific commands without producing JSON output. Add --verbose for detailed debug logs showing why a command was approved or rejected.
Yes, use the MMI_CONFIG environment variable to point to a different config directory. For example, set MMI_CONFIG=/path/to/project/.mmi to use a project-specific configuration.
Wrappers are safe prefixes that are stripped before checking the core command. For example, if timeout is a wrapper and pytest is approved, then timeout 10 pytest is approved. Wrappers don't make unsafe commands safe—they simply allow safe commands to be wrapped with approved prefixes.
Audit logs are written to ~/.local/share/mmi/audit.log in JSON-lines format. Each entry includes metadata (version, session/tool IDs, timestamp, duration), the command, approval status, detailed segment information with match or rejection details, and the working directory. Disable logging with --no-audit-log.
Common causes:
- Deny list priority: Deny patterns are checked first and override all approvals
- Command substitution: Commands containing
$(...)or backticks are rejected (except in quoted heredocs) - Command chains: If using
&&,||,|, or;, all segments must be approved - Pattern mismatch: Use
mmi validateto verify your patterns and--verboseto see why rejection occurred
Yes! Simply run mmi init again. If your config file already exists, it will print a notice and skip writing the config file, but will still configure Claude Code's settings.json (unless --config-only is set or the hook is already configured). Use --force only if you want to overwrite your config file.
Run the test suite:
go test -v ./...Run with coverage:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.outReleases are automated via GitHub Actions and GoReleaser.
-
Update the changelog - Move items from
[Unreleased]to a versioned section inCHANGELOG.md:## [0.1.0] - 2026-01-13 -
Create and push a git tag:
git tag v0.1.0 git push origin v0.1.0
-
Automated release - GitHub Actions will automatically:
- Build binaries for Linux, macOS, Windows (amd64 + arm64)
- Create the GitHub release with archives and checksums
- Update the Homebrew tap (
dgerlanc/homebrew-tap)
just release-testThis validates the GoReleaser config and performs a dry-run snapshot build.
See LICENSE file for details.