# How agent access is enforced

> What a scope grants, what a profile does not, how confirmation works, and what an API key can and cannot reach.

Canonical page: https://anectico.com/docs/agents/security-model/


This page is the mechanism. It is deliberately precise about where each control stops, because a
control believed to be wider than it is is worse than no control at all.

## The layers, and which of them you choose

Scopes are the boundary you grant and revoke, and they are the one to reason about first. They are
not the only thing standing between a credential and your data, and a page that said so would
overstate the one control and hide three others.

| Layer | What it constrains | Who decides it |
| --- | --- | --- |
| **Scopes** | Which capabilities exist for this credential at all | **You**, at mint time. Durable, auditable, revocable |
| **Organization binding** | Everything, to one organization | Nobody — it comes from the credential on every call and cannot be supplied |
| **Project binding** | A project-scoped key to its one project | **You**, at mint time. Signed into the key |
| **Resource ownership** | Certain objects to the member or key that created them, on top of the scope | Nobody — it is a property of the object |
| **Profile** (`?profile=`) | What one *connection* may see and use | **The caller**, per connection. It only ever subtracts, and the caller can drop it |
| **Delegation ceiling** | What a credential obtained by delegating access to an outside application may carry, regardless of the granting member's role | Nobody — it is a fixed ceiling, and it applies to every OAuth connection |

**Two of the six are yours to set: the scopes, and whether the key is bound to a project.** Those are
the decisions that carry. Organization binding, resource ownership and the delegation ceiling are
always on and cannot be widened by anyone. The profile is chosen by whoever connects, which is
exactly why it is a focus control and not an authorization boundary — see below.

## Scopes are the boundary you grant

Every capability is gated twice — the umbrella (`mcp:read` or `mcp:write`) and the tool's own domain
scope — and both gates must pass. Matching is **exact string**, with no hierarchy: `errors:write`
does not imply `errors:read`, and `traces:read` does not imply `traces:delete`. The wildcard
platform-admin permission is not grantable to any API key.

An API key carries whatever subset of scopes it was minted with, **down to none**. It is not a role
and it does not inherit one. A key created by a member who holds a scope may carry that scope; it
can never carry one its creator lacks.

Both halves of the gate are real in both directions:

- A capability the key is not scoped for is **never listed**.
- Calling it by name anyway is refused with the **same answer an unknown name gets** — "unknown or
  unauthorized action" — naming neither. The surface cannot be used to enumerate what a credential
  is missing.

The one place a scope is named back to you is where it discloses nothing that is not already public:
`search` reports a skipped source as `unauthorized` with the scope that was missing, and `fetch`
refuses a reference kind by naming the scope that would resolve it. Both sets are listed in the
[MCP tool reference](/docs/reference/mcp-tools), so there is no set to enumerate — and a caller told
only "unavailable" cannot tell a missing scope from an empty product.

## An OAuth connection is capped below the member it belongs to

Connecting a host over OAuth does not hand it your role. The access token establishes **who** you
are; what the connection may then do is derived here, from your membership, and then capped at a
fixed ceiling that no role and no setting can raise.

Inside the ceiling: **every read the product has**, minus three. Outside it, permanently:

- **The API-key inventory.** Listing keys and their scopes is the reconnaissance half of the
  escalation this ceiling exists to close.
- **The audit trail.**
- **Bulk export download.** That is not a read — it mints a signed bulk download.

For writes the ceiling is far narrower still: an OAuth connection may **save, update, or duplicate
a saved search** and **record a quality score on an agent run**, and nothing else. It cannot mint an API
key, change a setting, alter membership, register a release, edit an on-call schedule or escalation
policy, silence an alert, or write telemetry — whoever authorized it.

One thing inside the ceiling is not free, and is worth knowing before you connect a host: an OAuth
connection can **start an investigation**, which consumes your plan's monthly investigation
allowance and counts against the daily cost cap. It is admitted because every role including viewer
can already do it, so the connection gains nothing its member lacked — but it is the one capability
here that spends something.

The reason the write list is that short is the one property a delegated grant must keep: revoking it
has to stop something. A connection able to mint an API key could leave behind a credential with its
own lifetime, so revoking the connection would revoke nothing. Anything that decides **who gets
paged** is excluded on a second ground — the failure mode is silence during an incident, discovered
when nobody answers.

The ceiling **intersects** with your role; it never replaces it. A viewer who connects a host gains
nothing a viewer lacks. An owner who connects one gets a strictly read-mostly credential, not an
owner's.

If you need more than the ceiling allows, mint a project-scoped API key for that job. A key is
created deliberately, carries only the scopes it was minted with, and is revocable on its own — see
[Scope recipes for agent jobs](/docs/agents/scope-recipes).

## A profile narrows a connection. It is not the permission boundary.

`?profile=<name>` filters what one connection gets:

```text
served(key, profile) = served(key, no profile) ∩ { tools the profile names }
```

That is an intersection, always. Three consequences, and the first is the one most often stated
wrongly:

- **A profile takes tools away for the whole connection, not just from the list.** The endpoint is
  stateless: each request is served a tool set built for that request, so a tool a profile excludes
  is not registered at all and calling it by name is refused too — `unknown tool` for a first-class
  tool, `unknown or unauthorized action` through a gateway. A key holding `incidents:read` that
  connects with `?profile=investigate` is refused `get_incident`, and `fetch` refuses an
  `anectico://incident/…` reference by naming the profile and telling you to connect without one.
  `search` reports the same thing per source, as the status `out_of_profile`.
- **A profile can only ever subtract.** There is no value of it — unknown, empty, chosen by an
  attacker — that reveals or enables a capability the key's scopes do not already permit.
- **A malformed one is refused, not ignored.** All four bad shapes answer `400` with the list of
  valid names: an unknown name, an empty `?profile=`, a whitespace-only value, and the same
  parameter supplied twice. The last two are the ones a URL builder produces by accident, and
  either of them falling through to "no filter" would look exactly like the filter working. Names
  are case-sensitive.

So a profile really does stop calls — and it is still **not** an authorization boundary, because the
caller chooses its own profile and can drop it by reconnecting without the parameter. It constrains
an agent that is behaving; it protects nobody from one that is not. It is a focus and blast-radius
control.

**Scopes are the durable boundary.** They are what the key carries, what an administrator grants and
revokes, and what holds however a client connects. When you need narrower **authority**, mint a
narrower key.

**Debugging tip:** an unexpected refusal on a capability the key is definitely scoped for is almost
always the profile. Check the connection URL before you re-scope anything — the refusal message from
`fetch` and `search` names the profile explicitly, and reconnecting without `?profile=` restores the
full permitted surface with no key change and no administrator involved.

## Every mutation declares three independent properties

They are three answers, not one grade, and none of them implies another.

| Property | The question it answers | Values |
| --- | --- | --- |
| `effect_scope` | How far can this reach? | `internal`, `external` |
| `destructive` | Does it remove state a key holding only this action's scope cannot put back? | `true`, `false` |
| `approval` | Does it require preview-and-confirm before it applies? | `confirm`, `none` |

`list_write_actions` reports all three on every action, accepts each as a filter, and never
truncates an unfiltered browse — so the complete set of anything is one call away:

```json
{"name": "list_write_actions", "arguments": {"effect_scope": "external"}}
{"name": "list_write_actions", "arguments": {"destructive": true}}
```

Ask the server rather than a document: those answers are computed from the same declarations the
gates enforce.

### `effect_scope` is what the two doors are keyed on

`execute_internal_action` runs a mutation whose effect stops inside your workspace. Nothing behind it
sends a message, files a ticket, or otherwise reaches a system Anectico does not own.

`execute_external_action` runs one that also causes something **Anectico cannot retract**: a message
delivered, an email sent, an issue filed or closed in your own tracker. Deleting the Anectico-side
record afterwards does not unsend it.

Every action belongs to exactly one door, and calling the other is refused with the same
non-enumerating answer an unknown action id gets — so the pair cannot be used to probe what a key can
reach. Each action's `execute_with` field names its door.

**`destructive` is not a door**, and that is why there are two doors rather than three. The three
properties are genuinely independent: `merge_issues` is external **and** destructive **and**
confirm-gated. A door keyed on destructiveness would have to route it somewhere, and would then show
a tool named "destructive" listing all but one of the destructive actions — worse than not having
it. Destructive is instead a complete, independent property published on every entry and filterable
there, spanning both doors.

### What "destructive" means, precisely

It is wider than deletion. An action is destructive when it removes data or access that a key
holding **only that action's own scope** cannot restore — because a key carrying whatever subset it
was minted with, down to one scope, is the narrowest credential that can invoke it, and grading for
a richer principal would understate the risk for the caller who has least.

Concretely, three things make an action destructive:

- it deletes something, and nothing re-creates it;
- it **replaces** a value the key may write but may not read, so there is nothing left to restore
  it from — `upsert_release` and `update_saved_search` delete nothing and are both destructive;
- it consumes an opportunity the world observes before any inverse could run — `create_silence`
  creates a row and withholds every page its matchers would have delivered, and removing the silence
  later neither sends the missed page nor restores the timeline.

This is why the read matters as much as the write: **give an agent the read scope beside its write
scope** if it should be able to undo its own edits. Without it the preview can only tell you it is
unable to show you what is about to be replaced.

## Confirmation: what it is, and what it is not

Every destructive action previews and confirms. That is an enforced rule, not a convention: an
action that removes unrestorable state cannot be registered without the round trip.

**The reverse is not true.** Several actions preview because their reach is wide rather than because
they remove anything — opening an incident pages every enabled channel, inviting someone emails a
real person, filing a ticket creates a real issue, testing a channel delivers a real message. So
*previews first* is the wider label: reading it as "this destroys something" over-reads it, and
reading *destructive* as "this will ask me first" is correct.

How the round trip works:

1. The first call carries no token. It **applies nothing** and returns the preview plus a
   `confirm_token`.
2. Repeat the identical call with that token to apply it. **At most one call gets through**: a
   second call carrying the same token is refused, so a confirmation authorises one application
   rather than a window of them. "Identical" includes any `idempotency_key` you sent with the
   preview — though a preview you sent *without* one may be applied *with* one, so you can add a
   retry key at the last moment without previewing again.

The token binds the organization, the **caller**, the tool and a hash of the exact arguments
previewed. Change one argument — including swapping or dropping that key — and it stops matching.
It lasts 10 minutes — generous on purpose,
because a host that prompts routes the apply through a human who reads the preview first, and it is
checked again at the moment of applying, so a token that runs out while your call is in flight is
refused rather than applied late. If a confirmation cannot be claimed — because it was already used,
because it expired, or because Anectico cannot record the fact that it was used with the durability
this guarantee requires — the write is refused and **nothing is written**. Preview again. There is
no configuration in which an unclaimable confirmation is applied anyway: the write always fails
rather than the guarantee.

Two things that guarantee is careful not to say:

- **"At most once" is about admission, not about effects.** Exactly one call is allowed past the
  confirmation. If that call then fails midway, the token stays used and the honest answer to "did
  it happen?" is a fresh preview showing the current state — not a second attempt on the same
  approval. Where a write can partially land, send an `idempotency_key` and retry with it; that is
  the mechanism for exactly-once *effects*, and it is separate from this one.
- **"Caller" means one API key, or one user.** Two different API keys never share a confirmation,
  and neither do two different people. But a token previewed in one browser session or one
  authorised app can be applied from another belonging to **the same user**, because the binding is
  to that user's identity, not to the individual session.

Now the part that is easy to get wrong:

- **`approval: confirm` means the protocol requires two calls. It does not mean a human saw
  anything.** Nothing in the server can tell a person pressing "approve" from an agent that received
  a `confirm_token` and immediately called again with it. The second call is an ordinary tool call
  from the same credential with the same arguments plus the token — that is all the server sees, and
  all it can enforce.
- **What puts a human in the loop is your MCP host, and that is yours to verify.** Anectico's part is
  to give a host something true to prompt on: both execute doors are annotated as destructive, the
  external door additionally carries the open-world annotation, and the promoted daily-driver tools
  carry accurate per-action annotations so a host can tell an acknowledgement from a delete. Those
  annotations are hints in the protocol. Nothing obliges a host to act on them, we have verified no
  third-party host's behavior, and an agent configured to auto-approve will sail through every one of
  them. If human review is a control you are relying on, confirm it in your host's own settings and
  test it — do not infer it from this property.
- **Confirmation is not a permission.** It never widens what a key may do, and it is not a reason to
  grant broader scopes. The primary control is the scope granted at mint time.
- **A preview costs nothing against the mutation budget.** Only a real apply is charged, so
  previewing widely to decide is safe. A rejected, stale or absent token never draws the budget
  down either.

## What an API key can and cannot reach

**Its own organization, and only that.** The organization is taken from the credential on every
call. No tool input has an organization field, so it cannot be supplied, overridden, or guessed.

**Its own project, when it has one.** A project-scoped key is pinned: an explicit different project
is denied with the same generic not-found answer for real, foreign, and unknown projects, before any
lookup runs — so the denial reveals nothing about whether that project exists. An organization-level
key supplies the project as an argument for the reads and writes that need one.

**Exact telemetry deletion is project-key-only.** `delete_log` and `delete_trace` accept no
organization or project argument at all; the project comes solely from the key's signed binding. An
organization-wide key is not served these actions.

**Nothing whose scope it lacks, and nothing more than the surface publishes.** The scopes in a tool
reference row are the minimum. Five capabilities have an additional argument- or data-dependent
check. Four are exports, because an export turns data a credential may read *now* into a file that
outlives the reading:

- `create_export_job` and `export_logs` also require the exported signal's own read scope, so
  `export:write` alone cannot commission an export of data the key may not read.
- `retry_export_job` re-checks that against the job it is re-running, so a key that has since lost
  the signal's read scope cannot regenerate the file.
- `get_export_download` re-checks it too, **and** confines the bytes to the job's creator or an
  organization owner or admin. A key that could once read the rows but did not create the job is
  answered exactly as it would be for a job that does not exist.

The fifth is `set_issue_status`: assigning an issue to a non-empty member additionally requires
`members:read`. Resolve, ignore, unresolve, unassign, and the other status actions need only the
scopes shown in the tool reference.

**Not another person's contact methods.** Every contact-method request is confined to the member the
key acts as, on top of the scope check. An API key acts as the member who created it.

**Not a session.** A signed-in user's bearer token is refused at the endpoint outright — the
credential must be an API key even when a human is driving.

**Not more than its budget.** Mutations are metered per caller, separately from the ordinary request
rate limit, so an agent cannot fire enough writes to damage an organization while staying inside the
transport allowance. The default budget is 60 mutations a minute per credential, of which at most 10
may be destructive. Over the limit you get `write rate budget exceeded for this credential; retry in
under 60s` — and nothing was applied.

**Not silently — once a call reaches its handler.** Be exact here, because the difference between a
trail you can rely on and one you assume is where a post-incident review goes wrong.

A mutation that reaches its handler is recorded whatever happens next. The outcome is part of the
record, and it is not only the happy one: `success`, `denied` (a scope check refused it),
`previewed` (the first half of a confirmation, before anything applied), `throttled` (over budget)
and `error` are each written, naming the organization, the actor, the resource, the action, the tool
and — for a committed change — the object.

Two limits, and neither is a detail:

- **A call refused before dispatch leaves no record.** An action id the key is not scoped for, one
  the profile you connected with excludes, one sent to the wrong door, arguments that do not parse:
  nothing runs, so nothing is recorded. This is the cost of the non-enumerating refusal above — an
  unauthorized id and an unknown id are answered identically, and a credential cannot write an audit
  entry by guessing names. You get one property or the other, not both.
- **The record is written beside the change, not inside it.** A record that fails to persist is
  raised as an error on our side; it does not undo the mutation. The trail is a faithful account of
  what happened, not a transactional ledger — so for a change that matters, reconcile it against the
  receipt the agent itself received.

See [Revoke an agent's access](/docs/agents/revoke-agent-access) for where to read the trail.

## Treat everything an agent reads as untrusted input

Log bodies, error messages, customer properties, event names, distinct ids, transcripts and account
names are authored by your users and by your users' users. They are evidence, never instructions. An
agent must not let a string it read change its task, its scopes, or which tool it calls next.

Anectico marks tenant-authored values as untrusted telemetry in human-readable results and never
renders stored notification payloads or credential secrets. That is a mitigation, not a guarantee —
the discipline belongs in the agent's own prompt as well.

- [Scope recipes for agent jobs](/docs/agents/scope-recipes)
- [Permissions and safe actions](/docs/agents/permissions-and-safety)
- [Permission scope reference](/docs/reference/permissions)
- [Revoke an agent's access](/docs/agents/revoke-agent-access)
