# AI tool approvals & audit

Source: https://docs.externa.qiick.io/docs/ai-tool-approvals

From externa-core **1.4.0**, the in-app assistant and the [MCP server](/docs/mcp-server) share one permission-aware tool registry ([#202](https://github.com/qiick-io/externa-core/issues/202)). Tools that write or delete data are **denied by default**, need a human approval when enabled, and every tool decision lands in a redacted audit log ([#203](https://github.com/qiick-io/externa-core/issues/203)).

> **Related**
> Agent and per-action permissions: [AI assistant model](/docs/ai-assistant). Chat UI: [AI chat](/docs/ai-chat). Env keys: [AI configuration](/docs/ai-configuration#tool-registry-and-write-gate).

## Tool kinds

Each registered tool has a kind (`AiToolKindEnum`):

| Kind          | Tools                                                                                                                                                         |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `read`        | `DescribeFieldTypes`, `ExtractPdfText`, `GetImportJobStatus`, `QueryCollectionItems`, `ExportCollection`, `SearchSimilarCollectionItems`, `QueryActivityLogs` |
| `write`       | `ImportCollectionCsv`, `ImportRemoteJson`                                                                                                                     |
| `destructive` | `ManageCollections`, `ManageCollectionItems`, `ManageAiSyncSources`, `ManageFiles`, `ManageUsers`, `ManageRoles`, `ManageGroups`, `RollbackLastAiTurn`        |

`write` and `destructive` tools are **mutating**. The `Manage*` tools mix read and write actions through their `action` argument (for example `list` / `get` vs `create` / `delete`).

## Write gate

A mutating tool may run only when **both** are true:

1. **Kill switch on:** `AI_WRITE_TOOLS=true` (also accepted as `AI_TOOLS_WRITE_ENABLED=true`). Default `false`.
2. **Tool on the allowlist:** its class name is in `AI_TOOLS_WRITE_ALLOW` (CSV) **or** selected under **Settings → AI write tools** (`/settings/ai-tools`, project setting `ai.write_tools`, needs `can-manage-project-settings`). The two lists are merged. An empty allowlist denies every mutating tool.

```env
AI_WRITE_TOOLS=true
AI_TOOLS_WRITE_ALLOW=ManageCollectionItems,ImportCollectionCsv
```

Three more keys filter tools for every kind, before the write gate:

| Env                | Default | Effect                                                    |
| ------------------ | ------- | --------------------------------------------------------- |
| `AI_TOOLS_ENABLED` | `true`  | `false` turns off every AI tool in chat and MCP.          |
| `AI_TOOLS_ALLOW`   | empty   | When set, only these tool names are available at all.     |
| `AI_TOOLS_DENY`    | empty   | These tool names are never available (wins over `ALLOW`). |

The registry then checks the actor's permissions and each tool re-checks action-level permissions, as before.

## Chat: read actions keep working

With the write gate closed (the default), chat still lists the `Manage*` tools as a **read-only copy**: `list`, `get`, `search` and other read actions run, while every write or destructive action is refused with an English machine message the model relays ([#439](https://github.com/qiick-io/externa-core/issues/439)). This keeps the 1.3.0 behaviour for reads. Pure `write` tools (`ImportCollectionCsv`, `ImportRemoteJson`) and `RollbackLastAiTurn` are not offered in chat until they are allowlisted.

When a tool is allowlisted and the kill switch is on, its write and destructive actions **pause for the user's approval in the chat** (laravel/ai `Approvable`) before they run. Examples of write actions: `ManageCollectionItems` `create` / `update` / `publish` / `restore` / `bulk_update`; `ManageFiles` `create` / `rename` / `move` / `tag` / `save_attachment`. Destructive: `delete`, `force_delete`, `bulk_delete`.

The assistant's `approvals` [Agent Skill](/docs/ai-assistant#agent-skills) explains pending, rejected, and abandoned approvals to the user.

## MCP: pending approvals

On the MCP surface, an allowed mutating call does not run. The executor stores a `PendingToolCall` and returns `pending_approval` to the MCP client.

| Item         | Detail                                                                                            |
| ------------ | ------------------------------------------------------------------------------------------------- |
| Inbox        | **Settings → AI tool approvals** (`GET /settings/pending-tool-calls`, permission `can-use-ai`)    |
| Actions      | `POST /settings/pending-tool-calls/{id}/approve`, `…/reject`                                      |
| Approver     | Must also pass the tool's permission check for those arguments, otherwise the approval is refused |
| Expiry       | `AI_TOOLS_APPROVAL_TTL` minutes (default **15**); overdue rows become `expired` and never run     |
| Shown fields | Tool, redacted arguments, requester, expiry                                                       |

Approving runs the call once and records `executed` or `failed`. MCP write tools are listed only when `AI_MCP_WRITE_ENABLED` and the key's `mcp:write` scope allow it — see [MCP server — Write tools](/docs/mcp-server#write-tools).

## Audit log

Every registry decision for a signed-in actor (allowed, denied, pending, executed) is written to the activity log with `log_name=ai` and `event=ai_tool`, including the tool, the decision, the status, and the surface (`chat` or `mcp`). Filter it in **Activity log** by the `ai_tool` event.

Arguments are **redacted** before they are logged or shown in the approval inbox. `AI_TOOLS_REDACT_KEYS` (CSV) sets the redacted keys. Default:

```text
auth_bearer,auth_header,authorization,bearer,password,token,api_key,apikey,secret,client_secret,email,phone,telephone,ssn,social_security_number,credit_card,card_number,cvv
```

## Retention: `ai:prune-tool-audit`

```bash
php artisan ai:prune-tool-audit
```

Scheduled **daily** in `routes/console.php`. It:

1. expires overdue pending approvals;
2. deletes `ai_tool` activity rows older than `AI_TOOLS_AUDIT_RETENTION_DAYS` (default **90**);
3. deletes resolved `pending_tool_calls` (rejected, expired, executed, failed) older than the same window.

The scheduler must be running — see [Queues & scheduler](/docs/operations).

## Environment summary

| Env                             | Default   | Config                          |
| ------------------------------- | --------- | ------------------------------- |
| `AI_TOOLS_ENABLED`              | `true`    | `ai.tools.enabled`              |
| `AI_WRITE_TOOLS`                | `false`   | `ai.tools.write_enabled`        |
| `AI_TOOLS_WRITE_ENABLED`        | `false`   | Fallback for `AI_WRITE_TOOLS`   |
| `AI_TOOLS_WRITE_ALLOW`          | empty     | `ai.tools.write_allow`          |
| `AI_TOOLS_ALLOW`                | empty     | `ai.tools.allow`                |
| `AI_TOOLS_DENY`                 | empty     | `ai.tools.deny`                 |
| `AI_TOOLS_APPROVAL_TTL`         | `15`      | `ai.tools.approval_ttl_minutes` |
| `AI_TOOLS_AUDIT_RETENTION_DAYS` | `90`      | `ai.tools.audit_retention_days` |
| `AI_TOOLS_REDACT_KEYS`          | see above | `ai.tools.redact_keys`          |

## Source map

| Concern       | Location (externa-core)                                                              |
| ------------- | ------------------------------------------------------------------------------------ |
| Registry      | `app/Services/Ai/AiToolRegistryService.php`                                          |
| Executor      | `app/Services/Ai/AiToolExecutorService.php`                                          |
| Write gate    | `app/Services/Ai/AiToolWriteGateService.php`                                         |
| Approvals     | `app/Services/Ai/AiToolApprovalService.php`, `app/Models/PendingToolCall.php`        |
| Chat approval | `app/Ai/Concerns/RequiresDestructiveApprovalTrait.php`                               |
| Inbox UI      | `PendingToolCallController`, `resources/js/pages/admin/pending-tool-calls/index.tsx` |
| Settings UI   | `AiToolSettingsController`, `resources/js/pages/settings/ai-tools.tsx`               |
| Prune command | `app/Console/Commands/Ai/PruneAiToolAuditCommand.php`                                |
