> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mirage.strukto.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Policies

> Write a policy. The hooks, what each can answer, and what the agent gets back.

A policy overrides only the hooks it needs. Each hook returns an answer,
or `None` for no opinion. Pass policies to the workspace, or add and
remove them later:

<CodeGroup>
  ```python Python theme={null}
  ws = Workspace(mounts, policies=[NoForcePush()])
  ws.policies.add(reports)
  ws.policies.remove(reports)
  ```

  ```typescript TypeScript theme={null}
  const ws = new Workspace(mounts, { policies: [noForcePush] })
  ws.policies.add(reports)
  ws.policies.remove(reports)
  ```
</CodeGroup>

Hooks come in two groups. TypeScript spells them in camelCase
(`preCommand`, `ctx.sessionId`).

## Shell hooks

These fire when an agent runs a line: `ws.shell`, `session.shell`, the
`shell`, `ls` and `grep` tools, `mirage shell`, HTTP `/shell`, RPC
`shell` and SSH.

| Hook | Fires | Reads | May answer | [Explain](/home/policy/explain#what-it-asks) asks it |
| - | - | - | - | - |
| `pre_command` | before each command | `command`, `argv`, `paths`, `operands`, `program`, `cwd`, `session_id` | Deny, Ask | yes |
| `pre_execute` | once per line, before it runs | `line`, `commands`, `cwd`, `env`, `session_id` | Deny, Route | yes |
| `post_execute` | after each line | `producer`, `exit_code` | Limit | no |
| `pre_session` | before each env write (`export`, `X=1`) | `verb`, `key`, `value`, `session_id` | Deny | no |

Every command on a line passes `pre_command` before any of them runs, so
a line refused whole never runs halfway.

### pre\_command

<CodeGroup>
  ```python Python theme={null}
  from mirage.policy import CommandContext, Deny, Policy

  class NoForcePush(Policy):
      async def pre_command(self, ctx: CommandContext) -> Deny | None:
          if ctx.command == "git" and ctx.argv[:1] == ("push",) and "--force" in ctx.argv:
              return Deny("force pushes go through an operator")
          return None
  ```

  ```typescript TypeScript theme={null}
  const noForcePush: Policy = {
    preCommand(ctx) {
      const force = ctx.command === 'git' && ctx.argv[0] === 'push' && ctx.argv.includes('--force')
      return force ? { kind: 'deny', reason: 'force pushes go through an operator' } : null
    },
  }
  ```
</CodeGroup>

`git push --force origin main` gets `git: Permission denied`, exit 126.

### pre\_execute

<CodeGroup>
  ```python Python theme={null}
  from mirage.policy import Policy, Route
  from mirage.runtime.routing import RouteContext

  class JobsInDocker(Policy):
      async def pre_execute(self, ctx: RouteContext) -> Route | None:
          if any(p.startswith("/jobs/") for c in ctx.commands for p in c.paths):
              return Route("docker")
          return None
  ```

  ```typescript TypeScript theme={null}
  const jobsInDocker: Policy = {
    preExecute(ctx) {
      const jobs = ctx.commands.some((c) => c.paths.some((p) => p.startsWith('/jobs/')))
      return jobs ? { kind: 'route', runtime: 'docker' } : null
    },
  }
  ```
</CodeGroup>

`python3 /jobs/train.py` runs on the workspace's `docker` runtime. See
[Route policy](/home/route-policy) for runtimes.

### post\_execute

<CodeGroup>
  ```python Python theme={null}
  from mirage import Limit
  from mirage.policy import ExecuteResultContext, Policy

  class FirstHundredLines(Policy):
      async def post_execute(self, ctx: ExecuteResultContext) -> Limit:
          return Limit(max_lines=100)
  ```

  ```typescript TypeScript theme={null}
  const firstHundredLines: Policy = {
    postExecute() {
      return new Limit({ maxLines: 100 })
    },
  }
  ```
</CodeGroup>

A longer output stops at 100 lines, and stderr says
`output truncated at limit (100 lines); ...`.

### pre\_session

<CodeGroup>
  ```python Python theme={null}
  from mirage.policy import Deny, Policy, SessionContext

  class NoCredentials(Policy):
      async def pre_session(self, ctx: SessionContext) -> Deny | None:
          if ctx.key.startswith("AWS_"):
              return Deny("credentials are set by the operator")
          return None
  ```

  ```typescript TypeScript theme={null}
  const noCredentials: Policy = {
    preSession(ctx) {
      return ctx.key.startsWith('AWS_')
        ? { kind: 'deny', reason: 'credentials are set by the operator' }
        : null
    },
  }
  ```
</CodeGroup>

`export AWS_SECRET=x` gets `AWS_SECRET: permission denied`, exit 1.

## VFS hooks

These fire on every VFS call: `session.vfs`, the `read`, `write`, `edit`
and `glob` tools, `mirage vfs`, HTTP `/vfs/<call>`, RPC `vfs/<call>`,
FUSE, FSKit and SFTP. `pre_vfs` also fires for every file a shell command
reads or writes.

| Hook | Fires | Reads | May answer | [Explain](/home/policy/explain#what-it-asks) asks it |
| - | - | - | - | - |
| `pre_vfs` | before each VFS call, and each file a command touches | `op`, `path`, `write`, `create`, `session_id` | Deny, Ask | for a VFS call, not inside a line |
| `post_vfs` | after each VFS call | `op`, `path`, `result` | Deny, Limit | no |

`pre_vfs` fires thousands of times under one `grep -r`, so keep it cheap.
An Ask from it reaches a host only from `session.vfs` or a file tool;
inside a line it refuses.

### pre\_vfs

<CodeGroup>
  ```python Python theme={null}
  from mirage.policy import Deny, Policy, VfsContext

  class ReadOnlyReports(Policy):
      async def pre_vfs(self, ctx: VfsContext) -> Deny | None:
          if ctx.write and ctx.path.virtual.startswith("/reports/"):
              return Deny("reports are read-only")
          return None
  ```

  ```typescript TypeScript theme={null}
  const readOnlyReports: Policy = {
    preVfs(ctx) {
      return ctx.write && ctx.path.virtual.startsWith('/reports/')
        ? { kind: 'deny', reason: 'reports are read-only' }
        : null
    },
  }
  ```
</CodeGroup>

Every command gets its own error, exit 1:

```text theme={null}
$ echo x > /reports/q3.md
/reports/q3.md: Permission denied
$ cp notes.md /reports/
cp: cannot create regular file '/reports/notes.md': Permission denied
```

`session.vfs.write("/reports/q3.md", ...)` raises `EACCES`.

### post\_vfs

<CodeGroup>
  ```python Python theme={null}
  from mirage import Limit
  from mirage.policy import Policy, VfsResultContext

  class SmallReads(Policy):
      async def post_vfs(self, ctx: VfsResultContext) -> Limit | None:
          if ctx.op == "read":
              return Limit(max_bytes=1_000_000)
          return None
  ```

  ```typescript TypeScript theme={null}
  const smallReads: Policy = {
    postVfs(ctx) {
      return ctx.op === 'read' ? new Limit({ maxBytes: 1_000_000 }) : null
    },
  }
  ```
</CodeGroup>

`session.vfs.read` returns at most the first 1 MB. `post_vfs` does not
see the reads inside a command, so `cat` is not cut; bound a line with
`post_execute`.

## Answers

| Return | The agent gets |
| - | - |
| `None` | nothing changes |
| `Deny(reason)` | `rm: Permission denied`, exit 126; from a VFS hook, the command's own error or `EACCES` |
| `Deny(reason, DenyScope.OPERAND, path=p)` | the command's own error for `p`: `cat: /data/k: Permission denied`, exit 1; the rest of the line still runs |
| `Ask(reason)` | `cp: Permission denied`, exit 126, until a host answers; see [Asks](/home/permissions#asks) |
| `Route(runtime)` | the line runs on that runtime |
| `Limit(max_lines=, max_bytes=)` | the output cut at the bound, with a notice on stderr; with `on_exceed=OnExceed.ERROR`, no output and exit 1 |

TypeScript returns `{ kind: 'deny', reason }`, with
`scope: 'operand', path` for an operand, `{ kind: 'ask', reason }`,
`{ kind: 'route', runtime }` and `new Limit({ maxLines })`. A hook that
raises refuses too.

The reason never goes to stderr. It is on the result's `refusal`:

```python theme={null}
r = await ws.shell("git push --force origin main")
r.refusal
# Refusal(kind='deny', reason='force pushes go through an operator', policy='NoForcePush', scope='command', ask_id=None)
```

The [agent tools](/python/access/in-app#session-tools) add one line to
their text: `policy denied: <reason>`, or
`requires approval: <reason> (ask <id>)`.

## When policies disagree

* Any Deny beats any Ask, so answering an ask never reopens a refusal.
* The first Deny speaks. Built-ins come first, then the session's
  profile, then `policies=`, then `add()`.
* Limits merge to the tightest value of each field.
* Two Routes to different runtimes refuse the line.

## Which door enforces what

A line fires every hook; a VFS call fires only the VFS hooks. So a
[profile](/home/permissions) rule that names a command holds only on a
line, and a rule that names only paths holds on both.
`session.tools.names()` lists only the tools the session's profile leaves
it, and MCP, RPC and the agent adapters offer exactly those.

## Examples

[policy.py](https://github.com/strukto-ai/mirage/blob/main/examples/python/permissions/policy.py)
and
[policy.ts](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/permissions/policy.ts)
run a coded policy beside a profile's `policy:` script. Their output is
pinned in CI.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.