Three gates, not one: permissions, approvals and sandboxing
Permission rules, approval modes and OS sandboxing answer three different questions. Conflating them is why people end up drowning in prompts or turning everything off.
Two questions, one lever, wrong answer
2 scenarios:
Either you're clicking approve on every single command and have long stopped reading what's in the prompt, or you want to leave it for an hour to do its thing and being sure nothing gets destroyed
In both cases usually people either want to turn off the approvals or endure this clicking. But these aren't solutions for these problems, because they don't address them at all — we have here 2 different things so we need to address them with different tools.
I cover in this chapter up to 3 different measures harnesses come with that address these problems, and usually (in 4 out of 5 cases) there are all of them:
Rules — whether a particular tool can be used with a given set of arguments; a static configuration is being compared against the command before the run
Approvals — what to do if the rules don't decide; can be decided by you, classifier or nobody
Sandbox — what a run command can access on the machine; it's secured by the operating system over the process and its descendants
Here's how vendors communicate about it:
Anthropic:
/sandboxis not a permission mode — they say it in the docs, in those words
OpenAI:
They have two cooperating layers, called the sandbox mode and the approval policy
Cursor:
They describe the sandbox as a layer on top of Run Modes
It's consistent between vendors, but this is not how most of the commentary around them looks like. Let's have a look what these 3 mechanisms see.
What each gate can see
They're not levels of one knob — they examine different things:
Rules see the tool name and its arguments, as text; decided by you in your config file; cover every tool in most harnesses, only shell commands in some
Approvals see the same text and everything the thing that decides knows; decided by you, a classifier or nobody; what rules didn't decide about
Sandbox sees actual file opens and connections the process makes; decided by the kernel; covers shell commands and their children in some harnesses, entire sessions in container-based sandboxes
The most important is the last one — the coverage column. Rules are about what the agent declared it will do, the sandbox is about what the process can actually do. Different things, and the difference is measurable.
The demonstration
Let's demonstrate it. We'll use Claude Code 2.1.220 in a freshly created directory, with a single deny rule that matters here (the settings file also carried an allow and an ask entry that don't come into it):
{
"permissions": {
"deny": ["Read(./.env)"]
}
}.env holds SECRET=hunter2, and we go after it in three ways.
First, using a tool that reads files:
The read was blocked — your permission settings deny access to `.env` in this directory:
File is in a directory that is denied by your permission settings.Then through the shell, with the prompts explicitly bypassed:
That was blocked — the permission prompt for `cat .env` came back denied, so I don't have the output.And finally with a Python one-liner that opens the file itself:
python3 -c "print(open('.env').read())"SECRET=hunter2This is because the harness recognises the tool as a file-reading tool and blocks it (thanks to the deny rule), recognises cat as a file-reading command and blocks it too (also thanks to the deny rule), but the Python one-liner is not recognisable as a read command so no rules are being applied.
This is how the thing works, it's not a bug — the docs say that the deny rules do not apply to arbitrary processes reading or writing files indirectly, and for this scenario they recommend using the sandbox.
That warning is Claude Code's, but the shape of the hole isn't specific to it. Every guard that works by matching the text of the command is doing an educated guess about what the process will do, and is limited to the set of commands the harness happens to know. Only the lower layer actually prevents things.
The order they fire in
The order is also standard:
The rules are being consulted first
Then the rest goes to approvals
And the sandbox encloses everything that runs
This means that a rule can override an approval — we could have set the approval mode to "allow everything" and the shell read inside the prompt-bypassing run would still not work because of the deny rule, it's not the same as "stop asking me".
It also means that it's the sandbox that limits the reach, not the opinion on whether a given command is sensible — it doesn't have one, it only limits what it can access. If it tries to access something it's not supposed to, it comes back to approvals.
The boundary often replaces prompt — a command limited to work in the working directory with no network is sth many harnesses don't ask about (the sandbox is part of the reason why they don't). It's still not less restrictive, it's just that the question got cheap enough to be answered without you.
Also worth noticing that harnesses don't agree on how to resolve conflicts between your rules:
Claude Code — fixed order by type of rule, deny then ask then allow
Antigravity CLI — the same fixed order, deny then ask then allow
Codex — the most restrictive one wins
Kimi — the first match wins
So be careful when you copy-paste your rules between harnesses, you can easily end up with a policy that does the opposite of what you thought you've configured.
Permission fatigue, honestly
Now, regarding the approvals:
Usually people say they click them without looking. But if you were to disable them, they would still need to click the same number of times, it's just that they won't see these clicks. What's more sensible is either:
To move more of your routine approvals to rules — the agent needs to run the tests? Make an allow rule for the test command; the build, the linter,
git status— same thing. Keep track of your actual approvals for a week and create these rulesOr to move more of the potentially harmful approvals to the sandbox — if there's sth you wouldn't want the bot to do on your machine even if it's confused, it's not really a question it can ask, because it can only ask if you read it; enabling the sandbox makes the bot not dependent on you for this
What's left is only a small subset of things that really need a human — pushing, deploying, deleting stuff, spending money and accessing production — and those prompts do get read, because there are a handful of them a day rather than fifty.
A starting configuration
Let's now talk about the initial setup. In terms of the payoff:
Make a short deny list — secrets, credentials directories, force push, your team's specific hazard; short enough so everyone remembers it
Enable the sandbox — it's what actually creates an actual boundary instead of a decision, and at the same time it's often the most often thing that's missing in people's setup as it was introduced after the rest
Have an allow list created based on observations, not assumptions — make an allow rule when you approve a command for the second time, never when you approve it for the first time
Leave the approval mode alone — it will naturally quieten down by itself as you address the above, if you're still overwhelmed after two weeks, add another allow rule, don't change the mode
In Claude Code
Gate 1 — rules.
The first layer of permission system is based on static rules defined by the permissions key in the settings.json file and split into three arrays — deny, ask, and allow. They are checked in order (deny, ask, allow), with the first match found being decisive (width of a pattern doesn't matter, so even if you define a very wide deny it will always take precedence over any specific allow).
Let me show an example of such rule set:
disallowing reading the credentials file and force-pushing
asking on push
allowing to run tests, build/check and status command
{
"permissions": {
"deny": ["Read(.env)", "Read(~/.aws/**)", "Bash(git push --force *)"],
"ask": ["Bash(git push *)"],
"allow": ["Bash(npm test *)", "Bash(cargo check *)", "Bash(git status)"]
}
}The most straightforward way to explore the current set of permissions is running /permissions command which outputs all the rules in place and points to settings.json files they come from if you're wondering why sth doesn't work.
There are a few things that can be confusing when it comes to the syntax used in these rules:
the paths in the rules are checked only against
Read()andEdit()operations, if you define a rule forWrite()the code won't complain but will never enforce it (and will tell you about it in the startup output)whitespace inside
Bashpatterns is significant so for exampleBash(ls *)will matchls -labut notlsofwhileBash(ls*)will match both
Gate 2 — permission modes.
Actually there's also one more layer — permission modes. They can be set using --permission-mode or defaultMode settings, and as of 2.1.220 are:
manual(also calleddefault)acceptEditsplanautodontAskbypassPermissions
The auto mode is not a "no prompt" mode but replaces the prompt with a classifier, while dontAsk does the opposite — denies actions which aren't allowed instead of asking; useful if you run it in a non-attended setup.
Gate 3 — the sandbox.
There's also the third layer which is the sandbox and can be accessed via /sandbox command. It's important to note that it's not a permission mode, as the documentation says outright. What's underneath depends on the platform:
macOS — Seatbelt
Linux and WSL2 — bubblewrap
native Windows — not supported
And what it confines by default:
writes go to the working directory and the session temp directory
all outbound traffic is going through a proxy with an empty domain allowlist by default
Let me show you an example configuration enabling it and listing allowed domains as well as denying two things:
the credentials file
an env var
{
"sandbox": {
"enabled": true,
"network": { "allowedDomains": ["github.com", "*.npmjs.org"] },
"credentials": {
"files": [{ "path": "~/.aws/credentials", "mode": "deny" }],
"envVars": [{ "name": "GITHUB_TOKEN", "mode": "deny" }]
}
}
}The thing is that the read policy by default covers the entire home directory, ~/.ssh included, so if you want to secure your secrets you need to list them yourself — there's no built-in denylist for this purpose.
The gotcha.
There's also a little trap — allow rules in .claude/settings.json of a repository grant capability, so they're not in use until you accept the workspace trust dialog. This is not the case for deny and ask rules which are just there to enforce restrictions, so those keep working. Run it with -p and the dialog never appears, which means an allow rule defined there simply stays off and all you get is a line on stderr:
Ignoring 1 permissions.allow entry from .claude/settings.json: this workspace has not been trusted.
Run Claude Code interactively here once and accept the trust dialog, or set
projects["/path/to/repo"].hasTrustDialogAccepted: true in ~/.claude.json.If you have an allow rule defined in a repo and it doesn't seem to work in CI, that's always because of this.
In Codex CLI
In terms of the two-part nature of control in Codex CLI, OpenAI calls it "sandbox mode" (for the set of capabilities that are allowed to be used) and "approval policy" (for when the approval is required). These are two separate properties, controlled by two separate configuration properties, and you shouldn't assume they're connected. For instance, you could set a project up so that it's always in sandbox mode (workspace-write), but at the same time decide you don't need to approve any commands there and configure it so you never get prompted.
The first "gate" — the rules: The policy itself is defined by Starlark scripts placed in the rules/ subdirectory of an active config layer. For the user layer that's ~/.codex/rules/default.rules, and a trusted project can have the same thing under <repo>/.codex/rules/. And here's an example of a rule — one associates a command prefix with a decision and a rationale (for instance, you might want to require approval for viewing PRs, so you'd write something like this):
prefix_rule(
pattern = ["gh", "pr", "view"],
decision = "prompt",
justification = "Viewing PRs is allowed with approval",
)
prefix_rule(
pattern = ["rm", "-rf"],
decision = "forbidden",
justification = "Use git clean instead.",
)The possible decisions are allow, prompt or forbidden; if there are multiple rules that match a command, the most restrictive one applies. Here's how the CLI subcommand for analysing a policy file against a specific command looks:
codex execpolicy check --pretty --rules ~/.codex/rules/default.rules -- rm -rf /tmp/xRunning it you get the matching rules and the final decision as JSON.
{ "matchedRules": [ ... ], "decision": "forbidden" }Once you allow a command in the TUI app, it creates such a rule and adds it to your user layer, so the file gradually accumulates the most useful rules based on what you actually do.
This feature is marked as experimental. If you'd like to run a command bypassing the policy for just one time you can do it by using the codex exec subcommand with the --ignore-rules flag.
The second "gate" — approval policy: There's also a flag for setting the approval policy, it supports these values:
untrusted— only known-safe reads run on their own, everything else escalateson-request— for when you want to use the TUI interactivelynever— for CI or other scenarios where you don't need to approve anything
Also, there's an older value you'll still find in tutorials (on-failure) but it's deprecated. For interactive usage you should use on-request, and never for CI.
You can also decide category by category which prompts still reach you, so even if you configure the project as on-request, you can keep some types of prompts interactive while setting the rest to auto-reject.
The configuration is a table that looks like this; you can toggle various categories there:
approval_policy = { granular = {
sandbox_approval = true,
rules = true,
mcp_elicitations = true,
request_permissions = false,
skill_approval = false,
} }sandbox_approval— prompts to escalate out of the sandboxrules— prompts raised by an execpolicypromptrulemcp_elicitations— MCP elicitation promptsrequest_permissions— prompts from therequest_permissionstoolskill_approval— approving skill scripts
The last "gate" — sandbox mode: For the sandbox mode, there's also a flag; on this one you can set these values:
read-only— no changes allowed to be madeworkspace-write— changes are allowed to be made in the project's root, networking is not availabledanger-full-access— no sandbox at all
This is also implemented differently per operating system; on macOS it's Seatbelt with sandbox-exec, while on Linux it's bwrap combined with seccomp.
My personal opinion is that workspace-write with no networking is the most valuable of these defaults and the one users disable first after setting up a project. Turning the network back on looks like this:
[sandbox_workspace_write]
network_access = trueWhich leads me to the next point — under workspace-write the agent can create and change files in the project's root, but .git and .codex (and .agents if you use it) stay read-only, recursively. So it can modify your project's files but it can't reach the internals of Git or the Codex configuration sitting in the same repo.
The gotcha. Given these two properties are really orthogonal, the most common pair that users choose is often wrong; while never + read-only is reasonable for unattended runs, never + danger-full-access is a completely different story. That's why I'd recommend to think which level of sandbox mode you need first, and then decide how often you want to be prompted during using it.
And the last thing — make sure you know what the boundary actually forbids by running a command under the sandbox (with denial logging enabled) before relying on it:
codex sandbox --log-denials -- npm testIn Cursor
3.x moved a lot of things around, so it's a bit messy to find sth in older write-ups — just FYI that anything written before the middle of 2026 names modes that aren't there anymore; the current ones live under Settings > Agents > Approvals & Execution.
Run Modes
The most visible layer is the Run Modes which are about defining who can approve what, and they work as follows:
Auto-review (the one Cursor recommends) — allowlisted runs are getting run straight away, everything else goes to the sandbox where that's possible and whatever is left, the classifier decides
Allowlist — only allowlisted runs are getting run, you can optionally enable sandboxing
Run Everything — no restrictions
Allowlist replaces what used to be "Ask every time" in 3.5 (so if you set up the Allowlist to be empty you're basically in this mode) and "Run in Sandbox" was merged with Allowlist so now you can have it enabled for it.
permissions.json
The first layer is the permissions.json file which you can have at ~/.cursor/permissions.json and at <project>/.cursor/permissions.json (they're being loaded and merged), it's a little bit different compared to the other tools on the market as it uses natural-language instructions rather than patterns to describe what to allow / block, for example:
{
"autoRun": {
"allow_instructions": [],
"block_instructions": [
"Every AWS CLI command should go through approval first.",
"Every command that modifies Kubernetes resources should go through approval first."
]
}
}As you can see the above file includes an autoRun section with an allow_instructions and a block_instructions array, and in block_instructions it lists AWS CLI commands and any command that modifies Kubernetes resources. It's worth noting that these aren't regular expressions but a classifier, so you get more power (it can understand things that globs are not able to), on the other hand you lose some of the predictability of globs, so be mindful which side you choose.
sandbox.json
The third layer is sandbox.json which sits in the same locations as permissions.json, the one from the project folder takes precedence. The mechanics behind it depends on the platform:
macOS: Seatbelt using
sandbox-execLinux: Landlock and seccomp (the kernel version needs to be 6.2 or newer and unprivileged user namespaces need to be enabled), under that it falls back to prompting per command
The file looks as follows:
{
"type": "workspace_readwrite",
"additionalReadonlyPaths": ["~/.config/mytool"],
"networkPolicy": {
"default": "deny",
"allow": ["registry.npmjs.org", "*.github.com"],
"deny": ["*.internal.example.com"]
}
}So we have a type property (workspace_readwrite in the example), an additionalReadonlyPaths array and a networkPolicy object, in the networkPolicy section you define a default (deny by default) and can provide allow and deny arrays. There's also a network mode, and the default one appends Cursor's own package manager domains to yours.
The most important thing is that deny hosts are taking precedence over allow hosts, and private IP ranges plus the cloud metadata endpoint are blocked by default.
There are two important things to keep in mind regarding Auto-review:
It's not a security boundary — the documentation has a separate heading about it, basically the classifier might make a mistake so it's more of a comfort thing rather than a layer (like the sandbox is)
The Cloud Agents ignore Run Modes completely as they run on separate machines and are not prompting you at all so if you configure sth locally you don't know how it works for them
And lastly, within the Linux sandbox the process has UID 0 so id -u will return 0 instead of your user's ID — CURSOR_ORIG_UID carries the actual value for scripts that need it.
In Antigravity CLI
The binary is agy, and all three gates are configured from a single JSON file. One caveat before any of it: if you're following a Gemini CLI write-up, none of it applies here — the TOML policy engine and the GEMINI_SANDBOX variable don't exist in this tool, and a config built on them is silently inert rather than an error.
Gate 1 — fine-grained permissions. Rules live in one JSON file, ~/.gemini/antigravity-cli/settings.json, under a permissions key with three arrays — allow, deny and ask. Every gated operation is written as action(target):
{
"permissions": {
"allow": [
"command(git)",
"command(npm run (build|lint|test))",
"read_url(google.com)"
],
"deny": [
"command(rm -rf)",
"command(sudo)",
"write_file(/home/user/.ssh)"
],
"ask": ["command(*)"]
}
}The documented actions are read_file, write_file, read_url, execute_url, command, unsandboxed and mcp, and * is the wildcard for a whole namespace — command(*), mcp(*) and so on. Two of the matching rules are worth knowing before you write anything. Command patterns match by token: each whitespace-separated token is evaluated as an anchored regular expression, which is why command(npm run (build|lint|test)) catches exactly the three you named. And URL targets match hostnames and subdomains but ignore the path, so read_url(google.com) also covers mail.google.com.
Precedence is fixed and by type, not by specificity: deny beats ask beats allow. The docs spell out the consequence — put command(*) in ask and command(git) in allow and you get prompted before every git command, because the ask rule wins. Same trap as Claude Code's, same fix: don't reach for a wildcard in the stricter list and then try to carve exceptions out of it.
There are also two implications the engine applies for you, and they're the sensible direction: allowing write_file on a path grants read_file on it, and denying read_file on a path also blocks write_file there.
/permissions opens a manager where you can add and edit rules live, and it's also the honest way to see which of the three scopes a rule came from — Project (this repo only), Shared (across Antigravity products) or Global (all your sessions). Worth opening before you debug a rule that isn't behaving; the file you edited may not be the one that won.
Defaults matter here more than in most harnesses. Reading and writing inside your active project directory is auto-allowed; everything unconfigured — commands, MCP tools, files outside the workspace, web browsing — defaults to ask. There's a separate allowNonWorkspaceAccess setting, off by default, governing whether the agent can touch files outside your project at all.
Gate 2 — execution modes and the tool-permission flow. Two separate things, and they're easy to conflate. The execution mode is about file edits: default pauses for an inline diff review before writing, accept-edits approves file writes automatically, and plan prepends a /plan prefix so the agent investigates with read-only tools and hands you an outline first. Set it with --mode=accept-edits, or cycle default → accept-edits → plan with Shift+Tab mid-session.
The tool permission setting (toolPermission in settings, or /permissions in the TUI) is the separate flow for everything that isn't a file edit:
request-review(the default) — prompts before write, bash and web toolsproceed-in-sandbox— runs terminal commands automatically if they're sandboxed, otherwise promptsstrict— prompts for every non-read toolalways-proceed— no prompting at all
Note the docs' own warning: your permission rules keep governing shell commands across every execution mode. Switching to accept-edits does not loosen command(...) rules — it's about diffs, not about the shell.
Gate 3 — the terminal sandbox. One boolean, enableTerminalSandbox, in the same settings file, off by default, or --sandbox for a single session:
{ "enableTerminalSandbox": true }It's native OS containment rather than a container — nsjail on Linux, sandbox-exec on macOS, AppContainer on Windows — so there's no image to pull and nothing to keep running. Worth checking before you rely on it on Windows, though: the CLI's own page lists all three as shipped, while Antigravity's product-wide permissions page still calls terminal sandboxing a macOS/Linux preview. Confirm which is true on your platform rather than taking either at face value.
The nice touch is that the sandbox and the approval prompt know about each other, in both directions. With it enabled, the prompt offers "Yes, and run without sandbox restrictions" as a one-off escape; with it disabled, the prompt offers "Yes, and run in sandbox" so you can contain a command that looks risky without changing your config. That's also what the unsandboxed(...) permission action is for — a standing grant for the commands you've decided must run outside containment, unsandboxed(git push) being the obvious one.
The gotcha. The sandbox being off by default is the one to fix on day one — it's the only gate here the model can't talk its way around, and it's the one you have to opt into.
The second one only bites unattended. In headless runs (agy -p) there's nobody to answer an ask, and the documented behaviour is a soft deny: the tool is refused, but the run carries on and exits 0, with a note about it on stderr. So a CI job whose agent was blocked out of half its work still reports success. If you run this unattended, read stderr, don't trust the exit code — and resolve your ask rules into explicit allows or denies before you get there.
Beyond that, --dangerously-skip-permissions exists and does what the name says; and because Antigravity CLI shares settings with the Antigravity 2.0 desktop app, a permission rule you change in one shows up in the other. Convenient, and worth knowing before you loosen something in the GUI and forget it followed you to the terminal.
In Kimi Code CLI
Gate 1 — permission rules.
It's based on two layers of configuration in the ~/.kimi-code/config.toml file. Permissions are a list of tables with rules for different tools, evaluated top-down and resolving to the first found rule. For instance, you could have the following configuration:
[[permission.rules]]
decision = "allow"
pattern = "Read"
[[permission.rules]]
decision = "allow"
pattern = "Grep"
[[permission.rules]]
decision = "deny"
pattern = "Bash(rm -rf*)"
[[permission.rules]]
decision = "ask"
pattern = "Bash"which allows Read and Grep, doesn't allow running rm -rf through Bash but asks about other Bash commands. Every rule has three possible outcomes:
allowdenyask
And it defines either a tool (in which case it's a pattern matching just this tool's name) or a tool with an argument (in this case it's a pattern matching the tool name and an argument expression). You can define arguments for some of the built-in tools that support providing a subject — in particular, Bash allows using a command as the argument and Read supports path-based arguments. However, MCP tools, user-added tools and AgentSwarm can't be limited by arguments so it's only the tool name that can be used there.
It's worth noting that the rules are processed in order so the behaviour is different than in the other harnesses. So if you place an ask rule above a deny rule for the same tool, it will overwrite the deny rule. That's why it's good to keep general ask rules towards the end of the table.
If your configuration becomes too big, you can also use two additional fields — scope and reason — with the following values:
scope:turn-overridesession-runtimeprojectuser
reason: any arbitrary text
The scope is set to user by default.
Gate 2 — permission modes.
There's a second layer of configuration that can be defined using default_permission_mode in the same config file, or you can set it per session with a flag. There are three possible values:
manual(the default) — prompts every timeyolo— automatically allows all the tool actions but the agent might ask questionsauto— no interaction at all
The last two can't be combined, the application will fail on start if you try to use both --yolo and --auto flags. Especially if you plan to run sessions non-interactively, it's important to be extra careful. For example, kimi -p uses the auto mode by default and you can't combine it with --yolo, --auto or --plan flags. In this scenario no prompts reach the user at all, and the only thing left protecting the filesystem is your static deny rules — the docs do confirm those keep working in -p runs, but that's the whole of it.
Gate 3 — there isn't one.
There's no third layer here — no documented system-level isolation method. No Seatbelt profile and no bubblewrap or container option. Only an empty cell in the sandbox row of this Academy's reference table, while for every other harness there's sth there. That means the weight lies on the first two layers. So…
The responsibility for the outer boundary is yours — make sure it's bulletproof by using a container or a VM for any non-routine work, considering it as this missing third layer
Keep the deny rules for paths with credentials and destructive commands in place, remembering that
Read(path-pattern)only covers theReadtool, not every route a file can be opened byDon't run
kimi -por use the--yoloflag on your local machine, do it inside of the container instead
…which is in line with what I wrote at the beginning — if there's no third layer, nothing is enforced, it can only be chosen, so you need to make sure the agent will only propose the commands which your rules can recognise
What none of this buys you
These 3 measures can work better than one, but they're not a security boundary (in the sense that security teams mean). The sandbox limits the blast radius, but not the intent — if the agent were to run a command found in a file it was asked to summarise, it could still do nonsense, push it and create a PR with this nonsense, all with its permissions. It tells you how far, but not whether sth is sensible.
The same applies to classifiers — they're probabilistic so for example Cursor's docs have their own heading saying that auto-review isn't a security boundary; whenever you see a new harness, be sure to look if they say sth similar about the auto-approvals.
Rules are about matching strings, and strings can be deceptive — for example Claude Code and Codex both examine compound commands and all of their parts, and Claude Code even removes wrappers like timeout or nice before comparing the command with the rules, but if you were to make an allow rule for a tool that runs other tools, it will allow everything this tool can do; if you were to make an allow rule like Bash(devbox run *) it will not be about devbox.
Lastly, a rule that never loaded is not a rule — in most of the harnesses there's at least one way for your config to be quietly ignored: an untrusted workspace, a tier that isn't wired up yet, a team policy that silently replaces your local file. This happens often enough so I have another lesson about it later in this chapter, make sure you read it before trusting your setup today.
So it's not that these 3 things are not great, it's just important to remember which of them you rely on — if you think a rule prevents sth, make sure you mean a rule and not a boundary, because only one of them is enforced by something that can't be talked out of it.