mmi: A Permission Layer for Claude Code

If you’ve used Claude Code for any length of time, you know The Ritual. Claude wants to run git status. You hit allow. ls to peek at the directory? Allow. pytest? Allow. Allow allow allow allow allow. You’re a bored customs agent waving through an endless line of tourists in khaki shorts. Each one clutching a fanny pack and a Lonely Planet guide. Each one radiating absolute zero threat energy. You’re stamping their passports one by one by one while your soul leaves your body in slow motion. Nobody’s smuggling anything. Nobody has ever smuggled anything. The sniffer dogs are asleep. And yet! The protocol must be observed!

I built mmi (short for “Mother May I?”) because I got tired of being that customs agent.

The Idea

Here is the thing. Ninety-something percent of the commands Claude Code wants to run are completely, boringly safe. Reading files. Checking git status. Running the test suite. These are the khaki-shorts tourists, the ones with the zip-off legs and the SPF 70. I would approve every single one without a flicker of thought, and the approval prompt adds nothing at that point. Dozens of times per session for an audience of one increasingly annoyed developer.

But I also didn’t want to rip out the velvet rope entirely. Some commands genuinely deserve scrutiny. Anything involving sudo, destructive file operations, commands that could rearrange the furniture of your system in ways you didn’t ask for. Those should still require a human to say yes.

mmi sits between Claude Code and Bash with a three-layer model:

  1. Deny patterns get checked first and are always rejected
  2. Wrappers like timeout or environment variables are stripped before the core command is checked
  3. Safe commands are explicitly allowlisted and auto-approved

Everything else falls through to the normal approval prompt. If mmi doesn’t recognize a command, you still get asked. The system fails closed, which is the only sane default when you’re letting an LLM talk to your shell.

How It Works

Under the hood, mmi uses a real shell parser (mvdan.cc/sh) to understand command structure. This matters more than you’d think. A naive approach that just string-matches command names would get absolutely bamboozled by command chains, pipes, and shell constructs. When Claude runs something like git status && pytest, mmi parses that into two separate commands and validates each one on its own.

Command substitution ($(...) and backticks) are rejected by default because they can hide arbitrary commands inside what looks like a perfectly innocent operation. The one exception is content inside quoted heredocs, which gets treated as literal text.

Configuration is TOML-based and supports includes, so you can compose configs for different stacks. I’ve included example configs for Python, Node.js, and Rust that cover the typical commands for each.

Getting Started

If you have Go installed:

go install github.com/dgerlanc/mmi@latest
mmi init

Then add mmi as a hook in ~/.claude/settings.json:

 1{
 2  "hooks": {
 3    "PreToolUse": [{
 4      "matcher": "Bash",
 5      "hooks": [{
 6        "type": "command",
 7        "command": "mmi"
 8      }]
 9    }]
10  }
11}

The default config is conservative: mostly read-only Unix utilities and git operations. Copy one of the example configs if you want broader coverage.

Example Configuration

The config file lives at ~/.config/mmi/config.toml. Here’s a trimmed version showing two patterns from each section:

 1# Deny list - always rejected, checked first
 2[[deny.simple]]
 3name = "privilege escalation"
 4commands = ["sudo", "su", "doas"]
 5
 6[[deny.regex]]
 7pattern = "rm\s+(-[rRfF]+\s+)*/"
 8name = "rm root"
 9
10# Wrappers - stripped before checking the core command
11[[wrappers.command]]
12command = "timeout"
13flags = ["<arg>"]
14
15[[wrappers.regex]]
16pattern = "^([A-Z_][A-Z0-9_]*=[^\s]*\s+)+"
17name = "env vars"
18
19# Commands - safe patterns that get auto-approved
20[[commands.simple]]
21name = "unix-and-shell"
22commands = ["ls", "cat", "grep", "find", "wc", "head", "tail"]
23
24[[commands.regex]]
25pattern = "^(true|false|exit(\s+\d+)?)$"
26name = "shell builtin"

simple matches a command name with any arguments. regex gives you full control when you need it. There’s also a subcommand type for tools like git where you want to allow git status and git log but not git push.

A Note on Risk

Let me be straight with you. Letting an LLM execute Bash commands in a non-sandboxed environment is, on some level, insane. mmi makes it less painful, not less risky. You’re trusting your own judgment about what belongs on the allowlist, and you’re trusting that Claude Code won’t get creative with those commands in ways you didn’t anticipate.

For my workflow, the bet is worth making. The commands I auto-approve are things like running tests, checking git status, listing directories. Stuff I’d wave through without looking up from my coffee. Your tolerance for this sort of thing might be different, and that’s fine. The deny list is right there.


The project is open source on GitHub. Inspired by a post from Matt Rocklin about managing Claude Code permissions.

Technical Appendix

Implementation Details

Getting the shell parsing right was the part where I learned humility. The obvious approach, splitting on && and |, works great right up until those characters appear inside quoted strings, heredocs, or other shell syntax, at which point it works terribly. I ended up pulling in mvdan.cc/sh, a full shell parser that builds an actual AST from the command. The real thing. No shortcuts.

The parser handles all the constructs: if/then/else, while and for loops, case statements, subshells, the whole menagerie. For each construct, mmi walks the AST recursively and pulls out individual commands. A for loop like for f in *.go; do go fmt "$f"; done gets broken down to the go fmt "$f" inside it. Every extracted command has to pass the safety checks on its own merits.

One gloriously annoying edge case: heredocs. Claude Code loves writing files with patterns like:

1cat > file.go << 'EOF'
2fmt.Printf(`template string`)
3EOF

Those backticks in the heredoc body are not command substitution. They’re literal characters in a Go template string. But mmi’s dangerous-pattern detector would see backticks and, quite reasonably, freak out. The saving grace is the quoted delimiter ('EOF'), which tells the shell to skip expansion inside the body. mmi’s parser catches this and excludes quoted heredoc content from the danger check, so legitimate file writes don’t get flagged.

The wrapper-stripping is worth mentioning too. Safe wrappers like timeout 30 or env VAR=value get peeled off iteratively from the front of the command until you reach the actual thing being run. So timeout 10 env FOO=bar pytest correctly identifies pytest as the command, with timeout and env noted as wrappers in the audit log. Like unwrapping a present, except the present is a test runner.

One last detail: even when a command gets rejected, mmi evaluates every segment of a command chain. If you run safe_cmd && unsafe_cmd && another_safe_cmd, all three get evaluated and logged, even though the whole thing gets rejected at the second segment. This keeps the audit log complete. You can see exactly what Claude was trying to pull, not just the thing that tripped the wire.

Audit Logging

My favorite feature. Every decision mmi makes gets written to a JSON-lines log file with full details: what command was evaluated, whether it was approved, which pattern matched (or why it was rejected), how long the decision took.

You end up with a complete paper trail of what Claude Code did during a session, and it’s genuinely useful for tuning your config over time. Spot a command getting rejected that you’d always approve? Add it. See something getting approved that makes you squint? Add a deny pattern. The log is your feedback loop. Use it.

Enjoyed this post?

I write about software, AI, and leadership. Subscribe to get new posts delivered to your inbox.