# Agent Security Model

Reference for the four risk tiers, the 7-gate execution order, the secrets vault, allowlists, and egress logging as of the agent power-up security foundation (shipped 2026-05-13).

---

## Risk tiers

Every tool in `MARKETPLACE_TOOLS` carries a `risk_level`. The tiers, from most to least restrictive:

| Tier | Behavior | Examples |
|------|----------|---------|
| `blocked` | Never executes. Throws immediately. | `sudo`, `rm -rf`, `git reset --hard` (via `classifyCommand`) |
| `irreversible_external` | Always creates an approval request — even when trust mode is `auto`. | `gh_pr_merge`, `email_send`, `supabase_rest_delete`, `supabase_sql` |
| `high` | Gated in `approval_required` mode; auto-approved in `auto` mode. | `file_write`, `file_patch`, `process_kill`, `supabase_rest_update`, `gh_pr_create` |
| `medium` | Same gate as `high`; policy can vary by terminal command classification. | `terminal_run`, `process_start`, `gh_issue_create`, `supabase_rest_insert` |
| `low` | Always executes. No approval gate. | `file_read`, `web_search`, `gh_repo_read`, `email_status_lookup` |

`classifyCommand` in `lib/tools/safety.js` can elevate a `terminal_run` or `process_start` call to `irreversible_external` at runtime (e.g. `git push --force`, `kubectl delete`, `DROP TABLE`).

---

## 7-gate execution order in `executeRegistryTool`

Gates run in this order. Failing a gate returns immediately without reaching later gates.

1. **Preflight** (`PREFLIGHTS[toolName]`) — synchronous checks run before any auth or gate logic. Used by file/terminal adapters to validate paths and commands.
2. **Irreversible-external gate** — if the tool's risk is `irreversible_external` (or `classifyCommand` returns `irreversible_external` for terminal/process calls) and no `agent_id` is set: create an approval request and return. Trust mode has no effect here.
3. **Trust-mode gate** — if the tool is flagged `approval_required` (for the main agent), check the global trust mode. In `approval_required` mode, create an approval request and return. In `auto` mode, proceed. Skipped if the irreversible gate already consumed the call.
4. **Agent permission gate** — if `agent_id` is set, check `agent_tool_permissions` table. Missing or `disabled` permission throws. `approval_required` permission creates an agent-scoped approval request.
5. **Allowlist gate** — if the tool has `allowlist_required: true`, extract the scope value from `input` using `allowlist_scope_key`, then call `assertAllowed`. Returns `{ status: 'allowlist_violation' }` if the destination is not listed (and the tool is default-deny).
6. **Rate-limit gate** — call `checkLimit(toolName, agentId, toolDefinition)`. Returns `{ status: 'rate_limited', retry_after_seconds }` if the sliding window is exhausted.
7. **Adapter call** — `await ADAPTERS[toolName](input, { owner_trusted, agent_id })`. After success, `recordCall` is invoked to register the event for the rate limiter.

Audit rows (`audit_logs`) are written at gates 2, 3, 4, 5, and 6 when they block, and at step 7 on success.

---

## Secrets vault

**File:** `lib/secrets.js`, `lib/master-key.js`  
**Table:** `secrets`

- Encryption: AES-256-GCM, 12-byte random IV, 16-byte auth tag, stored in three separate BLOB columns (`value_encrypted`, `iv`, `auth_tag`).
- Master key: resolved from `MADARX_MASTER_KEY` env var only. No Keychain. On first install with an empty table, a random key is generated and written to `.env.local` with a warning comment.
- Scope: `global` (default) or `agent:<agent_id>`. A lookup for an agent scope falls back to `global` if no agent-scoped row exists.
- Rotation: creates a new `active` row, marks the old row `rotated`. A partial unique index (`idx_secrets_one_active`) enforces exactly one `active` row per `(name, scope)` without blocking accumulation of `rotated` rows.
- Cache: decrypted values are cached in-process in a `Map<cacheKey, string>`. Cache is invalidated on rotate/delete. This keeps adapter hot paths synchronous after the first read.
- Process.env fallback: during the migration window, if a secret is not in the vault but is present in `process.env`, it is used and a one-time `console.warn` is emitted. Migrate with `node scripts/migrate-env-to-secrets.mjs`.

Values are never returned by the API, never written to audit rows, and never logged.

---

## Allowlist semantics

**File:** `lib/tool-allowlists.js`  
**Table:** `tool_allowlists`

Each tool that writes to an external service defines:

- `allowlist_required: true` — the gate runs.
- `allowlist_scope_key` — which dimension to check. One of: `recipient_domain`, `repo_full_name`, `table`, `function_name`, `channel_id`, `zone_id`, `customer_id`.
- `allowlist_default_allow: false` (default) — an empty allowlist blocks all calls. The owner must add at least one entry.
- `allowlist_default_allow: true` — an empty allowlist permits all calls. Used by `web_search` and `web_fetch` where open-web access is the point.

Scope value extraction in `executeRegistryTool`:
- `recipient_domain`: derived by splitting `input.to` at the last `@`. The adapter also validates that `input.recipient_domain` matches this derived value before calling Resend.
- All other keys: `input[allowlist_scope_key]` directly.

The `whatsapp_allowlist` table is separate and is not managed by `lib/tool-allowlists.js`. It has different semantics (inbound sender allow vs. outbound destination allow) and is checked by `lib/whatsapp-router.js`.

---

## Adding a new tool adapter

The GitHub adapter (`lib/tools/adapters/github.js`) is the reference implementation. Follow this pattern:

1. Create `lib/tools/adapters/<name>.js`. Export one function per tool. Accept `(input, options)` where `options.agent_id` is the calling agent (or undefined for main agent) and `options.owner_trusted` is true if approval was already consumed.
2. Fetch credentials with `await getSecret('<SECRET_NAME>', { agent_id: options.agent_id })` from `lib/secrets.js`. Never use `process.env` directly in adapters.
3. Wrap all outbound `fetch()` calls with `wrapFetch(toolName, agentId, fetch)` from `lib/egress.js`.
4. Return curated shapes — never raw API response blobs. Large or sensitive fields should be summarized.
5. Add `MARKETPLACE_TOOLS` entries in `lib/tools/registry.js` with:
   - Correct `risk_level` (`irreversible_external` for anything that cannot be undone: merges, sends, deletes).
   - `allowlist_required: true` and `allowlist_scope_key` for any write tool that targets a specific destination.
   - `allowlist_default_allow: false` for write tools (default-deny until owner adds an entry).
   - `rate_limit: { window_seconds, max }` appropriate for the service's API limits.
   - `enabled_default: false` for all integration tools.
   - `required_env: ['SECRET_NAME']` so the connector readiness check surfaces missing credentials.
6. Add the adapter function to the `ADAPTERS` map in `lib/tools/registry.js`.
7. Write tests in `tests/<name>-adapter.test.js`. Test the integration path through `executeRegistryTool`, not just the adapter function directly — the registry layer propagates `allowlist_required` and other fields that unit tests on the adapter alone would not catch.

---

## Egress log as a debugging tool

The `tool_egress` table records every external API call. Use it to audit, debug, or detect unexpected traffic.

Query examples (run against `data/madarx.sqlite`):

```sql
-- All calls to a specific host in the last hour
SELECT tool_name, method, status_code, bytes_in, duration_ms, at
FROM tool_egress
WHERE host = 'api.resend.com' AND at >= datetime('now', '-1 hour')
ORDER BY at DESC;

-- Error rate by tool in the last 24 hours
SELECT tool_name, COUNT(*) AS calls,
       SUM(CASE WHEN status_code >= 400 OR error IS NOT NULL THEN 1 ELSE 0 END) AS errors
FROM tool_egress
WHERE at >= datetime('now', '-24 hours')
GROUP BY tool_name;

-- Calls by a specific agent
SELECT * FROM tool_egress WHERE agent_id = 'agt_abc123' ORDER BY at DESC LIMIT 50;
```

The HTTP API equivalent is `GET /api/tool-egress?host=api.resend.com&since=2026-05-13T00:00:00Z`.

No request or response bodies are stored. The `error` column contains the thrown error message if the underlying `fetch` call failed — this message may include details from the remote server and should be treated as potentially sensitive in log aggregators.

## Scope resolution for secrets

V1 adds project-aware secret resolution. A secret lookup resolves in the following order:

1. `agent:<id>` when an agent id is supplied.
2. `project:<id>` when a project id is supplied.
3. `global`.
4. `process.env` as a migration fallback.

The secrets table includes `project_id` so the vault can distinguish project-level credentials while preserving the existing encrypted value model. Secret values are never returned from list endpoints and are never stored in audit payloads.
