AI Development

Why AI Coding Agents Need Permission Control: Shell, Git, and Filesystem Policy as JSON (2026)

About 16 min read

The security piece already covered the attack surface. This one is only the permission model: a tool call is a JSON object; execution sits on the host. Shell, Git, and the filesystem are three doors. Clicking Allow is not the sandbox closing.

Before you read

Door What it locks Who executes
Shell Command strings, compound commands allow / ask / deny; sandbox is separate
Git status, diff, commit, push; hooks / config Workspace write ≠ repo metadata write
Filesystem src, .env, paths outside the workspace Tool rules + OS sandbox
Network Domains, outbound Off by default; open a list

The host evaluates policy. The model fills JSON. It does not move the door.

Permission control exists because the model does not execute

When a coding agent calls a tool, the model only emits JSON: a name and arguments. The host is what runs git status or edits src/orders/A-1042.json. Schema gates shape. Permission policy gates whether a well-shaped call may run. Without the second door, the first is politeness.

  • Schema: lock the arguments. A command is a string. A path stays under an agreed prefix.
  • Permission policy: allow / ask / deny. Official order is deny, then ask, then allow. A narrow allow does not punch a hole in a wider deny.
  • Sandbox: an OS layer. If a prompt injection steers the model, the process still cannot leave the workspace. An approval click is not the sandbox opening.

How tool JSON is filled: AI coding agents and JSON tool calling. How the attack surface is collected: The agent security crisis and JSON validation. Why Schema is a gate: Why AI needs JSON Schema. This text does not repeat those three. It only lands Shell, Git, and files as objects.

The permission object: allow, ask, deny

Claude Code writes rules under permissions in settings.json. The host evaluates them, not the model. CLAUDE.md can change what Claude tries. It cannot change what Claude Code allows. Fictional repo — no real secrets:

{
  "permissions": {
    "allow": [
      "Bash(git status)",
      "Bash(git diff *)",
      "Bash(npm test *)",
      "Read(./src/**)",
      "Edit(./src/**)"
    ],
    "ask": [
      "Bash(git commit *)"
    ],
    "deny": [
      "Bash(git push *)",
      "Read(./.env)",
      "Edit(./.git/hooks/**)",
      "Edit(./.git/config)"
    ]
  }
}

A bare tool name such as Bash in deny removes the tool from context. A parenthesized rule blocks matching calls and leaves the tool listed. Compound commands match per subcommand: allowing git status does not allow a second command after it. Codex uses permission profiles (:workspace / :read-only) and must not mix them with the older sandbox_mode. Cursor uses a prefix list in permissions.json, plus sandbox.json.

First door: Shell

The model hands over a command string. The host matches policy before it runs anything. A read-only lookup usually passes:

{
  "type": "tool_use",
  "id": "call_7f2a",
  "name": "Bash",
  "input": {
    "command": "git status"
  }
}

In the same policy, a remote push is deny. The object is well-shaped. Execution still sits on the host. Cursor’s terminal list matches prefixes: git covers git status. Official docs also say Run Modes are best-effort guardrails, not a hard boundary. For a hard boundary, turn the sandbox on.

{
  "type": "tool_use",
  "id": "call_8a01",
  "name": "Bash",
  "input": {
    "command": "git push origin main"
  }
}

Second door: Git is not ordinary disk writes

A writable workspace is not a writable .git. Codex, under writable roots, keeps .git, .agents, and .codex read-only by default; if .git is a gitdir: pointer, the resolved directory is protected too. Claude Code’s sandbox lets a worktree write into the shared .git enough to commit, and still denies hooks/ and config. Cursor likewise protects .git/hooks and .git/config. A blocked push should leave an object:

{
  "tool": "Bash",
  "command": "git push origin main",
  "eval_order": ["deny", "ask", "allow"],
  "matched": "Bash(git push *)",
  "rule": "deny",
  "sandbox": "workspace-write",
  "execute": false
}

On failure, write back a tool_result that names the matching rule. Do not silently touch the remote. Put commit on ask: a person sees the diff. Put push on deny unless this session is explicitly a publish session.

{
  "type": "tool_result",
  "tool_use_id": "call_8a01",
  "is_error": true,
  "content": "denied by policy: Bash(git push *)"
}

Third door: the filesystem needs two layers

One layer is tool rules. The other is the OS sandbox. Official docs split them because they stop different calls:

  • Tool rules: Claude’s Read / Edit deny covers built-in file tools and recognized commands such as cat / head. A language script that opens files itself is outside this layer.
  • OS sandbox: a process boundary. Default writes stay in the working directory and the session temp dir. Extra paths are explicit. Network is off by default.
  • Credential files: put .env in deny. Do not rely on a prompt that says “do not read secrets”.

MCP tools are another table. Each tool still needs its own gate. Protocol contrast: MCP Tool Schema vs Function Calling. This text does not cover how coding agents call MCP. It only requires that a connected tool has a name in the permission object.

Five steps to an executable policy

Do not start from “please be careful”. Write the doors:

  1. Write deny first. Push, hooks, config, .env, and paths outside the workspace, once.
  2. Put read-only commands on allow. status, diff, test. One rule per subcommand for compounds.
  3. Put writes and commits on ask. A person sees the path and the diff. An edit approval dies with the session; it does not become a permanent rule by itself.
  4. Turn the OS sandbox on. Codex: :workspace, not casual :danger-full-access. Cursor: do not default to Run Everything. Claude’s bypassPermissions stays in an isolated container.
  5. Review the policy as JSON. Spread allow next to deny. Shared rules go in version control. Machine-local widening stays in local files.

How Schema becomes a gate: Agents API + JSON Schema. This text only asks that every door on a coding agent is an object the host evaluates.

To review a permission miss, spread two JSON traces

A crossed door is usually a misaligned rule, not a wilder model.

  • format the policy next to that tool_use. See whether deny matched or allow leaked.
  • With JSONPath extract, pull $.permissions.deny and $.input.command.
  • JSON Diff a pass and a deny. Fix the rule before you fix the prompt.
  • Review Git-metadata rules apart from src writes. Editing A-1042.json is not editing hooks.
  • Do not paste policy files, repo paths, or session logs into a public formatter. JSONSort runs locally in the browser.

If both traces rebuild against the rules, you collected a permission model. Otherwise you collected “I clicked Allow”.

Common traps

  • Treating Schema as permission. Schema gates shape. A well-shaped git push still needs deny.
  • Writing only allow. Official order evaluates deny first. Without deny, the list only speeds up approvals.
  • Treating workspace write as Git write. hooks and config are another door. Official defaults protect them.
  • Tool deny without a sandbox. A script that opens files itself is outside tool rules. You need a process boundary.
  • bypass / full-access / Run Everything on a laptop. Official docs keep those for isolated environments. Daily sessions use workspace + ask.
  • Mixing Codex’s old and new systems. If sandbox_mode is present, permission profiles do not apply. Pick one.

FAQ

Does “allow and don’t ask again” mean permanent grant?

Not always. In Claude Code, a lasting Bash approval lands in the repo root’s settings.local.json; a file-edit approval dies with the session. Cursor’s list lives in permissions.json. Read the object that was written. Do not trust the button label.

If the sandbox is on, do I still need deny?

Yes. The sandbox stops a process leaving the workspace. deny stops commands that must not run inside it, such as push. The two layers lock different doors.

Why is Git its own door?

Repo metadata is not an ordinary file. Being able to edit src is not being able to edit hooks and config. push is outbound, not a disk write. Official products lift these out of “workspace writable”.

Is read-only enough?

Enough to read code. Not enough to change A-1042. Codex :read-only, Claude plan, and Cursor’s read-only sandbox are explore profiles, not publish profiles.

Can the policy live in Git?

Yes, and it should. Shared deny / allow belongs in version control. Local widening stays in local files and is not committed. Managed settings are how you turn off bypass and auto.

Conclusion

Coding agents need permission control because the model only fills JSON. Shell, Git, and the filesystem are three doors. After Schema passes, the host still evaluates.

Write down which deny git push would hit. If you can point at a path, the policy is verifiable. Models change. The doors should settle first.

Further reading

Changelog: First published