> ## 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.

# Permissions

> Profiles decide what each session sees and may run; asks are questions a host answers.

A **profile** decides what one session sees and may run. Profiles live on
the workspace; a session binds one at creation, for life. The
[policy engine](/home/policy-engine) enforces it; the
[route policy](/home/route-policy) picks runtimes.

```yaml theme={null}
mode: WRITE
mounts:
  /repo:
    vfs: ram
  /vault:
    vfs: ram

profiles:
  guarded:
    commands:
      allow: [ls, cat, grep, rm]
      ask:
        - reason: removal needs sign-off
          commands: [rm]
      deny:
        - reason: credentials are never read by hand
          paths: ["/vault/*"]
  auditor:
    commands:
      allow: [ls, cat, grep]
    paths:
      hide: [/vault]
```

```bash theme={null}
mirage workspace create workspace.yaml --id demo
mirage session create demo --id agent_a --profile guarded
mirage shell -w demo -s agent_a -c "cat /repo/notes.md"
```

In code: `Workspace(mounts, profiles=...)` and
`ws.create_session("agent_a", profile="guarded")` (TypeScript:
`new Workspace(mounts, { profiles })`,
`ws.createSession('agent_a', { profile: 'guarded' })`).

* No profile means `profiles.default` if defined, else unrestricted. The
  workspace's default session follows the same rule, so `default` also
  governs bare `ws.shell(...)` and `ws.vfs`.
* `ws.session("agent_a", profile="guarded")` returns one handle for both
  doors, `handle.shell(line)` and `handle.vfs.read(path)`. It creates the
  session or adopts an existing one. `Session(ws, "agent_a")` only adopts.
* **A line sets the session; a VFS call inherits it.**
  `ws.vfs.read(path, session_id=...)` applies only outside a running
  line.

## How rules are read

* **Command axis**: rules without paths, by verb: `deny` before `ask`.
  `allow` decides whether a word is a command at all.
* **Path axis**: rules with paths, and hides, by **anchor depth** (literal
  components before the first wildcard). The deeper entry wins whatever
  its verb: `/runbook/frozen/*` beats `/runbook/*`. Ties break by verb.
* **Hiding is not refusing.** A hidden path is `ENOENT` and leaves no
  record; a denied path is listed and fails when used.

### `commands`

`allow` is the session's tool set. A word not in it is
`command not found` (exit 127). Omitted means everything, empty means
nothing, and builtins count: `allow: [cat]` leaves no `echo`.

```yaml theme={null}
deny:
  - reason: pushes go through CI
    commands: [git push]    # whole lines, by prefix
  - reason: the rollback plan is frozen
    commands:               # one command, on these paths
      rm: ["/runbook/frozen/*"]
  - reason: credentials are never read by hand
    paths: ["/vault/*"]     # any command, these paths
```

A pattern is a prefix of the line: `git push` covers
`git push origin main`, `*` matches one word, a bare `*` every command.

| Rule | The agent reads |
| - | - |
| `deny` by command | `git: Permission denied`, exit 126 |
| `deny` by path | the command's own error, `cat: /vault/k: Permission denied`, exit 1 |
| `ask` | `rm: Permission denied`, exit 126, until a host answers |

The reason is only on the result's `refusal` record. Text surfaces append
`policy denied: <reason>` or `requires approval: <reason> (ask <id>)`.

A rule also covers what a command reaches on its own: every entry of a
walk (`grep -r`, `tar`, `cp -r`, `find -delete`), files a command opens
(`awk print >`, `sed w`) and cached bytes. Only rules with paths and no
command reach the VFS-only doors (FUSE, `ws.vfs`, SFTP).

Not covered yet: file access inside a CLI or interpreter (`git`,
`python3`) and in `getfattr`, `setfattr`, `ln`, `readlink`; backends that
search on their own side (`grep -r` on a GitHub mount).

### `paths` and `vars`

```yaml theme={null}
paths:
  hide:
    - /vault
    - patterns: ["*.env"]
      reason: dotenv files carry credentials
  show:
    /vault/policies: read
```

`hide` takes exact paths (with their subtree) or patterns. `show` reopens
a deeper subtree with a mode. A hide's `reason` is for operators only.
`vars.hide` makes variables read as unset.

### `mounts`

Narrows one mount: a lower `mode`, patterns anchored at the mount, and
`ask` / `deny` rules for lines working inside it by cwd or operand.

```yaml theme={null}
profiles:
  oncall:
    mounts:
      /repo:
        mode: read
        paths:
          hide: ["*.env"]
      /runbook:
        commands:
          deny:
            - reason: the rollback plan is frozen
              commands:
                rm: ["/runbook/frozen/*"]
```

### `cwd`, `env`, `policy`

`cwd` and `env` seed the session. `policy` is a script defining any of
`pre_command(ctx)`, `pre_ops(ctx)` and `pre_session(ctx)` (`preCommand`,
`preOps`, `preSession` in JavaScript). `pre_command` returns `None`,
`{"deny": reason}` or `{"ask": reason}`; the others allow or deny. A file
defining no hook fails closed. The script may read files, through the
same gate.

```yaml theme={null}
profiles:
  reviewer:
    policy:
      script: ask_curl.py
      runtime: monty
```

```python ask_curl.py theme={null}
def pre_command(ctx):
    for path in ctx["command"]["paths"]:
        if "curl" in contents(path):
            return {"ask": "approve before a command reads it"}
    return None

def contents(path):
    try:
        return open(path).read()
    except OSError:
        return ""
```

## Asks

An `ask` holds the line. The agent reads `rm: Permission denied` at exit
126 with a `pending` record:

```json theme={null}
{"kind": "pending", "reason": "removal needs sign-off", "policy": "", "scope": "command", "ask_id": "5b25c31eb62e"}
```

The question waits in `ws.decisions` until a host answers:

* `allow`, scope `once` (default): the retry runs and spends it.
* `allow`, scope `session`: every line the rule covers, from now on.
* `deny`: the retry is refused; running the line again asks again.

An `ask` with paths and no command also asks from a file tool or
`session.vfs` when no line is running. `allow once` then covers one tool
call.

### From the CLI

```bash theme={null}
mirage workspace list-asks demo                # pending asks
mirage workspace list-asks demo --session agent_a --all
mirage workspace allow demo 5b25c31eb62e --note "reviewed"
mirage workspace allow demo 5b25c31eb62e --scope session
mirage workspace deny demo 5b25c31eb62e --note "not now"
```

### Over REST

```bash theme={null}
GET  /v1/workspaces/{id}/asks                # pending; ?all=true for every decision
GET  /v1/workspaces/{id}/asks?session_id=agent_a
POST /v1/workspaces/{id}/asks/{ask_id}
     {"answer": "allow", "scope": "once", "note": "reviewed"}
```

A record has `id`, `session_id`, `agent_id`, `command`, `argv`, `cwd`,
`paths`, `reason`, `outcome` (null while pending), `scope` and `note`.
An unknown id is 404, an answered one 409, and `deny` with scope
`session` 422.

### In code

`ws.decisions` has `pending(session_id)`, `list(session_id)` and
`answer(ask_id, outcome, scope, note)`. Pass `on_ask` (`onAsk`) to answer
inline while the line waits.

How a grant is spent:

* Every command of a line is judged before it runs. A grant belongs to
  one place on the line, so a command spelled twice asks twice, and a
  loop body is one place.
* Grants are spent when the line ends, whether or not the command ran.
* Nested lines (`$( )`, `eval`, `source`, `xargs`) use the outer line's
  grants: `echo $(cat secret.txt)` asks once.
* A background job keeps its line's grants until it ends.
* A refused retry spends the refusal; the next run asks again.

A command only expansion reveals (`cat $F`, `xargs cat`, `find -exec`, a
path after a `cd` to a variable) is asked at its own gate, possibly after
earlier commands ran; a pending answer does not undo them.

## Explain

`session.explain` shows what a call would do, without doing it:
`explain.shell(line)` for `session.shell(line)`, and
`explain.vfs.<call>(...)` for each POSIX VFS call on `session.vfs`
(`read`, `write`, `pwrite`, `stat`, `rename`, `setxattr`, ...). Nothing
runs, nothing is recorded and no grant is spent. A policy that reads a
file to decide still reads it; its writes fail with `EROFS`.

```python theme={null}
explain = (await ws.session("reviewer")).explain
shell_res = await explain.shell("rm /data/x")
vfs_res = await explain.vfs.write("/vault/key", b"x")
```

```typescript theme={null}
const explain = (await ws.session('reviewer')).explain
const shellRes = await explain.shell('rm /data/x')
const vfsRes = await explain.vfs.write('/vault/key', 'x')
```

Every result has `outcome` (`allow`, `deny`, `ask`), `reason`, `source`
(where the rule is written: `top`, `mounts./runbook`, `commands.allow`),
`answers` (each policy's answer, in order) and `refusal`.

| Result | Adds |
| - | - |
| line (`ShellExplanation`) | `exit_code` and `stderr` the agent reads if the line is refused whole, else 0; `answers` from placement; `node`, the parse tree |
| command (a tree node) | `command`, `argv`, its own `exit_code` and `stderr`, `operands` (`text`, `path`, `matched`), `runtime` |
| VFS call (`VfsExplanation`) | `call`, `paths`, `error` (`EACCES`, `EROFS`, or empty) |

A line is refused whole by a rule or an unanswered ask; anything else
fails only its own command. Tree nodes are `list`, `pipeline`,
`subshell`, `group`, `substitution`, `line` (`bash -c`, `eval`, `xargs`)
and `command`. `mirage workspace explain` prints it:

```text theme={null}
cat /data/a | wc -l && echo $(cat /data/keys/a)  [deny, exit 1: sealed]
  list: cat /data/a | wc -l && echo $(cat /data/keys/a)
    pipeline: cat /data/a | wc -l
      cat /data/a  [allow]
      wc -l  [allow]
    echo $(cat /data/keys/a)  [allow]
      substitution: cat /data/keys/a
        cat /data/keys/a  [deny, exit 1: sealed]  top
```

* A hidden path explains like a path no rule matches.
* `list_files` is judged at its listing, not per entry.
* In TypeScript without async task isolation (a browser),
  `explain.vfs` throws and `explain.shell`'s policies act for real.

The same results come over `POST /v1/workspaces/{id}/explain/shell`, RPC
`explain/shell` and `explain/vfs/<call>`, and `mirage workspace explain`.

## Examples

* Roles over the same mounts:
  [permissions.py](https://github.com/strukto-ai/mirage/blob/main/examples/python/permissions/permissions.py),
  [permissions.ts](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/permissions/permissions.ts).
* A coded policy beside a profile `policy`, and explain:
  [policy.py](https://github.com/strukto-ai/mirage/blob/main/examples/python/permissions/policy.py),
  [policy.ts](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/permissions/policy.ts);
  from YAML:
  [workspace.yaml](https://github.com/strukto-ai/mirage/blob/main/examples/python/permissions/workspace.yaml),
  [guard.py](https://github.com/strukto-ai/mirage/blob/main/examples/python/permissions/guard.py),
  [policy\_yaml.py](https://github.com/strukto-ai/mirage/blob/main/examples/python/permissions/policy_yaml.py),
  [policy\_yaml.ts](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/permissions/policy_yaml.ts).
* Asks answered inline:
  [ask.py](https://github.com/strukto-ai/mirage/blob/main/examples/python/permissions/ask.py),
  [ask.ts](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/permissions/ask.ts);
  from the ledger:
  [ask\_pending.py](https://github.com/strukto-ai/mirage/blob/main/examples/python/permissions/ask_pending.py),
  [ask\_pending.ts](https://github.com/strukto-ai/mirage/blob/main/examples/typescript/permissions/ask_pending.ts).

Their output is pinned in CI.


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