# agent-perms

[![npm version](https://img.shields.io/npm/v/agent-perms.svg)](https://www.npmjs.com/package/agent-perms)
[![License](https://img.shields.io/badge/License-Apache--2.0-lightgrey.svg)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/Mearman/agent-permissions/ci.yml?branch=main)](https://github.com/Mearman/agent-permissions/actions)

A vendor-neutral permission policy format for AI coding agents. One file works across Claude Code, OpenAI Codex, OpenCode, Crush, and any agent that adopts the spec.

## Quick start

Create `.agents/permissions.json` in your project root:

```json
{
  "$schema": "https://github.com/Mearman/agent-permissions/releases/latest/download/agent-permissions.schema.json",
  "defaultMode": "standard",
  "rules": [
    { "tool": "Bash", "pattern": "sudo:*", "tier": "deny" },
    { "tool": "Read", "pattern": "./.env", "tier": "deny" },
    { "tool": "Bash", "pattern": "npm publish:*", "tier": "deny" },
    { "tool": "Bash", "pattern": "git status", "tier": "allow" },
    { "tool": "Bash", "pattern": "git:*", "tier": "allow" },
    { "tool": "Read", "tier": "allow" },
    { "tool": "Grep", "tier": "allow" },
    { "tool": "Bash", "pattern": "git push:*", "tier": "ask" },
    { "tool": "Bash", "pattern": "npm run *", "tier": "allow", "when": { "cwd": "./packages/*" } }
  ]
}
```

Every rule has a `tool`, an optional `pattern`, a `tier` (deny/ask/allow), and optional `when` conditions. Evaluation is deny-first: all deny rules are checked, then ask, then allow. Falls back to `defaultMode` when no rule matches.

**Zero-translation migration:** `jq '.permissions' .claude/settings.json > .agents/permissions.json` still works. The schema accepts Claude Code's `permissions.allow/deny/ask` arrays and the loader normalises them into rules.

## Why

Every coding agent has its own permission config. Teams using multiple agents (or migrating between them) maintain separate, often contradictory permission files. This spec provides:

- **One policy, many agents**: write once, convert to any agent's native format
- **Zero-translation migration**: Claude Code's `permissions` block is valid input
- **Superset coverage**: expresses features from all supported agents (sandboxing, named profiles, per-agent overrides, conditional rules)
- **IDE support**: JSON Schema for autocomplete and validation ([on SchemaStore](https://schemastore.org))

## File location

| File | Purpose | Git |
|---|---|---|
| `.agents/permissions.json` | Team-shared policy | Committed |
| `.agents/permissions.local.json` | Personal overrides | Gitignored |

Both files are merged at load time. Deny rules from any source short-circuit before allow rules.

## Installation

### As an MCP server

#### CLI shorthand

Some agent harnesses provide a one-command install:

**Claude Code (MCP):**

```bash
claude mcp add agent-perms -- npx -y agent-perms-mcp
```

**Claude Code (plugin marketplace):**

```
/plugin marketplace add https://github.com/Mearman/agent-permissions.git
/plugin install agent-perms@agent-perms
```

**OpenAI Codex:**

```bash
codex mcp add agent-perms -- npx -y agent-perms-mcp
```

#### Manual configuration

For harnesses that use config files, add the following to the `mcpServers` section:

```json
{
  "agent-perms": {
    "command": "npx",
    "args": ["-y", "agent-perms-mcp"]
  }
}
```

| Harness | Config file | Config key |
|---|---|---|
| Claude Code | `.mcp.json` (project) / `~/.claude.json` (user) | `mcpServers` |
| Codex | `~/.codex/config.toml` | `[mcp_servers.agent-perms]` |
| Gemini CLI | `~/.gemini/settings.json` | `mcpServers` |
| Crush | `.crush.json` / `~/.config/crush/crush.json` | `mcp` |
| Cline | `.cline/mcp.json` | `mcpServers` |
| Cursor | `.cursor/mcp.json` | `mcpServers` |

The MCP server is a background sync daemon. It exposes no tools, reads config from `.agents/permissions.json`, and keeps native agent config files in sync.

### As a library

```bash
pnpm add agent-perms
```

## Exports

The package uses [wildcard exports](https://nodejs.org/api/packages.html#subpath-patterns): import only what you need.

### Programmatic API (`agent-perms/api`)

Side-effect-free functions for use as a library:

```typescript
import { convert, validate, check, detectFormat } from "agent-perms/api";

// Convert between formats (auto-detects source)
const result = convert(undefined, "canonical", claudeCodeJson);
result.output;   // canonical object
result.from;     // "claude-code" (detected)
result.ruleCount; // 3

// Validate a policy
const { valid, errors } = validate(json);

// Evaluate a tool call
const { decision } = check("Bash", "sudo rm -rf /", policy, { branch: "main" });
// decision: "allow" | "deny" | "ask"

// Detect format from structure
const format = detectFormat(json); // "claude-code" | "crush" | "kiro" | ...
```

### Other modules

```typescript
// Zod schemas (single source of truth)
import { AgentPermissionPolicy } from "agent-perms/schema";

// Deny-first evaluator
import { evaluate } from "agent-perms/evaluate";

// Multi-layer policy loader
import { loadPolicy } from "agent-perms/loader";

// Bidirectional codecs for each agent
import { claudeCodeCodec } from "agent-perms/compat/codecs";

// SDK enum alignment checks
import { claudeCodeModes } from "agent-perms/compat/enums";

// Sync filesystem configs
import { sync } from "agent-perms/sync";
```

## Schema overview

```typescript
import { type AgentPermissionPolicy } from "agent-perms/schema";

// All fields are optional. A valid policy can be as minimal as `{}`.
interface AgentPermissionPolicy {
  $schema?: string;

  // Default mode: standard | autonomous | restricted | readonly
  // Also accepts Claude Code modes: plan | dontAsk | acceptEdits | bypassPermissions
  defaultMode?: PermissionMode;

  activeProfile?: string;

  // Permission rules (deny-first evaluation)
  rules?: Array<{
    tool: string;         // e.g. "Bash", "Read", "mcp__github__*"
    pattern?: string;     // absent = match any input for this tool
    tier: "allow" | "deny" | "ask";
    when?: { cwd?: string; branch?: string };  // AND logic
  }>;

  // Claude Code compat: string rule arrays (normalised to rules on load)
  permissions?: {
    allow?: string[];
    deny?: string[];
    ask?: string[];
    additionalDirectories?: string[];
    defaultMode?: PermissionMode;
  };

  profiles?: Record<string, PermissionTiers>;

  delegation?: {
    maxDepth?: number;
    nonDelegable?: string[];
    bubbleUp?: boolean;
    agents?: Record<string, PermissionTiers>;
  };

  sandbox?: {
    mode?: "readonly" | "workspace-write" | "full-access";
    writableRoots?: string[];
    networkAccess?: boolean;
  };

  network?: {
    enabled?: boolean;
    domains?: Record<string, "allow" | "deny">;
  };

  env?: Record<string, string>;
}
```

## Rule syntax

Rules use `Tool(pattern)` strings inside `permissions` arrays, compatible with Claude Code's permission format. In the unified `rules` array, the tool and pattern are separate fields:

| Rule object | `permissions` string | Type | Matches |
|---|---|---|---|
| `{ tool: "Read" }` | `Read` | Bare | All invocations of `Read` |
| `{ tool: "Bash", pattern: "git status" }` | `Bash(git status)` | Exact | Exactly `git status` |
| `{ tool: "Bash", pattern: "npm:*" }` | `Bash(npm:*)` | Prefix | `npm` + space + anything |
| `{ tool: "Bash", pattern: "git commit *" }` | `Bash(git commit *)` | Wildcard | `git commit` + anything |
| `{ tool: "Bash", pattern: "domain:evil.com" }` | `Bash(domain:evil.com)` | Domain | Commands containing `evil.com` |
| `{ tool: "mcp__github" }` | `mcp__github` | MCP server | All tools from `github` MCP server |

### Evaluation order

```
deny rules → ask rules → allow rules → defaultMode
```

Deny short-circuits: if any deny rule matches, the tool is blocked regardless of allow rules from any source.

### Escape sequences

| Escape | Meaning |
|---|---|
| `\(` | Literal `(` in pattern |
| `\)` | Literal `)` in pattern |
| `\*` | Literal `*` (not a wildcard) |
| `\\` | Literal `\` |

## Evaluator

```typescript
import { evaluate, type PermissionPolicy, type EvaluationContext } from "agent-perms/evaluate";

const policy: PermissionPolicy = {
  defaultMode: "standard",
  rules: [
    { tool: "Bash", pattern: "sudo:*", tier: "deny" },
    { tool: "Bash", pattern: "git:*", tier: "allow" },
    { tool: "Read", tier: "allow" },
  ],
};

// Returns "deny" | "ask" | "allow"
evaluate(policy, "bash", "git status"); // "allow"
evaluate(policy, "bash", "sudo rm -rf /"); // "deny"
evaluate(policy, "bash", "npm install"); // "ask" (falls through to defaultMode)

// With context for conditional rules
const ctx: EvaluationContext = { cwd: "./packages/api", branch: "main" };
evaluate(policy, "bash", "npm run build", ctx);
```

Tool names are matched case-insensitively (`Bash` matches `bash`).

### Converting string rules

```typescript
import { normaliseStringRule } from "agent-perms/evaluate";

// Convert Claude Code-style string rules to structured rules
const rule = normaliseStringRule("Bash(npm:*)", "allow");
// → { tool: "Bash", pattern: "npm:*", tier: "allow" }
```

## Policy loader

```typescript
import { loadPolicy } from "agent-perms/loader";

const policy = await loadPolicy({ cwd: process.cwd() });
```

Walks up from `cwd` looking for `.agents/permissions.json` and native agent configs. The policy file itself controls discovery via `with`, `without`, and `up` fields:

```json
{
  "with": ["claude-code", "opencode"],
  "up": 3,
  "rules": [...]
}
```

- `with`: only load these native configs (default: canonical only)
- `without`: load all except these
- `up`: how many parent directories to walk (default: `"all"`)

Loads and merges layers in order (outermost-first, last-defined-wins for `defaultMode`):

1. `.agents/permissions.json` (team-shared, discovered via walk-up)
2. `.agents/permissions.local.json` (personal overrides, discovered via walk-up)
3. Native agent configs (`.claude/settings.json`, `opencode.json`, etc.), if `with`/`without` enables them

The loader normalises all `permissions` string arrays into structured `rules`. Deny rules from any layer short-circuit. Allow rules are additive.

## Agent compatibility

Bidirectional codecs convert between the canonical format and each agent's native config:

```typescript
import { claudeCodeCodec, codexCodec } from "agent-perms/compat/codecs";

// Decode agent-native → canonical
const policy = claudeCodeCodec.decode(claudeSettings.permissions);

// Encode canonical → agent-native
const codexConfig = codexCodec.encode(canonicalPolicy);
```

| Agent | Native format | Codec | Fidelity |
|---|---|---|---|
| **Claude Code** | `Tool(pattern)` rule strings in `.claude/settings.json` | `claudeCodeCodec` | Lossless |
| **OpenCode** | Per-tool `ask/allow/deny` objects in `config.json` | `opencodeCodec` | Near-lossless¹ |
| **Codex** | Named profiles + sandbox in TOML config | `codexCodec` | Near-lossless² |
| **Crush** | Tool allowlist in `config.json` | `crushCodec` | Lossy³ |

¹ OpenCode's agent-specific tools have no canonical equivalent. Per-agent markdown overrides must be handled by the caller.

² Codex's `on-failure` approval policy and granular approval config have no canonical equivalent. TOML serialisation is the caller's responsibility; the codec works on parsed JS objects.

³ Crush has no deny, no patterns, no modes, only a bare tool allowlist. Pattern rules and deny rules are lost on encode.

### Zero-translation migration from Claude Code

```bash
jq '.permissions' .claude/settings.json > .agents/permissions.json
```

This works because the canonical spec accepts Claude Code's rule syntax, mode values, and `defaultMode` placement unchanged. The loader normalises `permissions` arrays into structured `rules`.

### MCP sync server

```typescript
import { startMcpServer } from "agent-perms/mcp";
```

A background sync daemon that keeps native agent config files bidirectionally synced with `.agents/permissions.json`. Exposes no tools; purely filesystem sync. Configured via the `sync` field in the policy file:

```json
{
  "sync": {
    "mode": "watch",
    "backup": true
  }
}
```

- `mode: "sync"`: one-shot sync on startup
- `mode: "watch"`: continuous sync via `fs.watch`
- `mode: false`: disabled

Also available as the `agent-perms-mcp` binary.

## CLI

The `agent-perms` binary converts, validates, syncs, and serves permission configs.

**All flags, no positionals.** Format names resolve to default config file locations.
Use `-` for stdin/stdout.

```
claude-code  →  .claude/settings.json
canonical    →  .agents/permissions.json
opencode     →  opencode.json
kiro         →  .kiro/permissions.json
codex        →  codex.toml
crush        →  .crush.json
```

### convert

```bash
# Format name → reads/writes default config locations
agent-perms convert --from claude-code --to canonical

# File paths: auto-detects format from contents
agent-perms convert --from .claude/settings.json --to crush

# Piping with -
cat settings.json | agent-perms convert --from - --to canonical --output -

# Write to specific file
agent-perms convert --from claude-code --to canonical --output my-policy.json
```

| Flag | Short | Aliases | Description |
|------|-------|---------|-------------|
| `--from` | `-f` | `--input`, `--in` | Source (format, file, or `-` for stdin) |
| `--to` | `-t` | | Target format or file (required) |
| `--output` | `-o` | `--out` | Output file (overrides `--to` path), or `-` for stdout |
| `--compact` | `-c` | | Single-line JSON |
| `--verbose` | `-v` | | Show decode/encode summary on stderr |

### validate

```bash
agent-perms validate --input canonical
agent-perms validate --input .agents/permissions.json
echo '...' | agent-perms validate --input -
```

| Flag | Short | Aliases | Description |
|------|-------|---------|-------------|
| `--input` | `-i` | `--in` | Policy file (format, file, or `-` for stdin) |

Exits 0 if valid, 2 with error details if not.

### check

```bash
agent-perms check --tool Bash --input "git status" --policy-file canonical
agent-perms check --tool Bash --input "git status" --policy-file .agents/permissions.json
```

| Flag | Description |
|------|-------------|
| `--tool` | Tool name (required) |
| `--input` | Tool input string (required) |
| `--policy-file` | Policy file (format, file, or `-` for stdin) |
| `--cwd`, `--branch` | Evaluation context |

Exits 0 with `allow` or 1 with `deny`.

### sync

```bash
agent-perms sync
agent-perms sync --dry-run
agent-perms sync -w claude-code -w opencode
agent-perms sync -x codex
```

| Flag | Short | Description |
|------|-------|-------------|
| `--working-dir` | `-d` | Starting directory (default: cwd) |
| `--up <n\|all>` | `-u` | Ascend n parent directories (default: all) |
| `--with <agent>` | `-w` | Only sync these agents (repeatable) |
| `--without <agent>` | `-x` | Sync all except these agents (repeatable) |
| `--yes` | `-y` | Apply without prompting |
| `--dry-run` | | Show changes only, never write |
| `--create` | `-c` | Create config files that don't exist |
| `--verbose` | `-v` | Show rule provenance |
| `--backup` | `-b` | Write `.bak` files before overwriting |

Sync merges rules with deny-first semantics (deny > ask > allow for same tool+pattern).
Most restrictive `defaultMode` wins.

### mcp

```bash
agent-perms mcp
```

Starts the MCP sync daemon on stdio. No flags; all config comes from `.agents/permissions.json` via the `sync` field. Typically invoked by agent harnesses via `npx agent-perms-mcp`, not run directly.

## JSON Schema for IDE support

The schema is included in [SchemaStore](https://schemastore.org). Editors that support it (VS Code, JetBrains, neovim) will automatically provide autocomplete and validation for `.agents/permissions.json` and `.agents/permissions.local.json` files with no configuration.

To explicitly reference the schema:

```json
{
  "$schema": "https://github.com/Mearman/agent-permissions/releases/latest/download/agent-permissions.schema.json"
}
```

Or reference locally:

```json
{
  "$schema": "./node_modules/agent-perms/agent-permissions.schema.json"
}
```

The schema file ships with the package at `agent-perms/agent-permissions.schema.json`.

## Examples

### Minimal: allow safe tools, deny secrets

```json
{
  "rules": [
    { "tool": "Bash", "pattern": "git status", "tier": "allow" },
    { "tool": "Bash", "pattern": "git diff:*", "tier": "allow" },
    { "tool": "Read", "tier": "allow" },
    { "tool": "Grep", "tier": "allow" },
    { "tool": "Read", "pattern": "./.env", "tier": "deny" },
    { "tool": "Bash", "pattern": "sudo:*", "tier": "deny" }
  ]
}
```

### Personal overrides (`.agents/permissions.local.json`)

```json
{
  "rules": [
    { "tool": "Bash", "pattern": "python3:*", "tier": "allow" },
    { "tool": "Bash", "pattern": "docker:*", "tier": "allow" }
  ]
}
```

### Rules: unconditional deny

Rules without `when` always apply, regardless of cwd or branch:

```json
{
  "rules": [
    { "tool": "Bash", "pattern": "npm publish:*", "tier": "deny" }
  ]
}
```

### Rules: conditional (cwd/branch)

Rules with `when` only match when all conditions are met (AND logic):

```json
{
  "rules": [
    {
      "tool": "Bash",
      "pattern": "npm publish:*",
      "tier": "deny",
      "when": { "branch": "main", "cwd": "./packages/core" }
    }
  ]
}
```

### Full policy with profiles, sandbox, per-agent overrides

See [`spec/examples/full.json`](spec/examples/full.json).

## Development

```bash
pnpm install          # Install dependencies
pnpm test             # Run tests
pnpm build            # Build ESM + CJS + types + JSON Schema
```

### Schema source of truth

The Zod schema in `src/schema.ts` is the single source of truth. The compiled JSON Schema (`agent-permissions.schema.json`) is generated via `z.toJSONSchema()`. Never edit it by hand.

### Adding a new agent codec

1. Define the agent's native schema in `src/compat/codecs.ts`
2. Implement `z.codec(nativeSchema, AgentPermissionPolicy, { decode, encode })`
3. Add round-trip tests in `src/test/compat.test.ts`
4. Register in the `CODECS` export
