Authorization & AI Safety

Scope-Gated Autonomous Agent: Permission-Bounded Tool Execution

TL;DR: An autonomous marketing agent (a Claude-driven reason→tool loop) where what the agent is allowed to do is bounded by least-privilege scopes, not by trusting the model. Tools are only registered into the agent's toolset when its service-account JWT actually holds the matching permission, every mutation is audit-logged, and compliance is enforced server-side where the agent can't reach. Runs live on Hetzner under systemd.

The interesting security idea here is that the agent’s safety doesn’t rest on the model behaving. Capability is decided below the model: a tool only exists in the agent’s toolset if the bot’s JWT carries the scope for it, every mutation is written to an audit trail, and the rules that actually matter — compliance, suppression, validation — are enforced by the backend the agent has to go through. The model is free to plan; what it can do is bounded by least privilege.

📋Summary

  • Problem: An autonomous agent that can email customers and modify a CRM is dangerous if its permissions live in the prompt — models can be coaxed into doing things they shouldn't.
  • Solution: Bound capability at the tool layer — register a tool only when the bot's JWT carries the matching scope — and enforce compliance server-side, outside the agent's reach.
  • Impact: The agent literally cannot call a tool it isn't scoped for; every action it takes is recorded; and regulatory compliance doesn't depend on the model behaving.
Authz
scope-gated tools
Audit
every mutation logged
Deploy
systemd on Hetzner

📋Context

The agent ("reason→tool loop" built on the Vercel AI SDK with Anthropic Claude) acts on a real CRM — it can search and create contacts, send single emails, run campaigns, and build automations. The security question isn't "is the model smart enough" but "what can it do at all, and who said so." The answer is enforced below the model, in how tools are wired and what the backend will accept.

Symptoms / Failure Modes

  • Prompt-based "you may not do X" guardrails are bypassable and don't constrain real capability
  • An over-permissioned agent could email unsubscribed contacts or mass-mutate the CRM
  • Without an audit trail, autonomous actions are impossible to review after the fact

🎯Goals, Requirements, Constraints

Goals

  • Bound the agent's real capabilities by least-privilege scopes, not by trust in the model
  • Make it impossible for the agent to invoke a tool it isn't authorized for
  • Record every state-changing action for review
  • Keep regulatory compliance (CAN-SPAM, suppression) outside the agent's control

Constraints

  • The agent authenticates to the backend with a scoped service-account (bot) JWT
  • All writes go through the backend API; the agent has no direct DB connection

Non-Goals

  • Relying on prompt instructions as a security control
  • Giving the agent direct database access

🏗️Approach

At startup the agent builds its toolset from the scopes present in its bot JWT. Read-only tools (knowledge retrieval, contact search) are always available; each write-capable tool is conditionally registered only when the corresponding scope is held — crm:write, email:send, email:blast, automation:fire. A tool that isn't registered simply doesn't exist for the model to call. Every mutating tool records an entry in an audit table, and compliance (unsubscribe footer, suppression of unsubscribed contacts) is enforced by the backend, which the agent must go through.

Key Design Decisions

  1. Decision: Gate capability at tool registration, keyed on JWT scopes
    Why: The most robust control is making an unauthorized action unrepresentable. buildTools() reads the bot's scopes and only assigns a write tool into the toolset when the scope is present, so the model can't call what was never registered.
    Alternatives: System-prompt restrictions — convenient but bypassable; they constrain phrasing, not capability.
  2. Decision: No direct database access for the agent
    Why: All mutations flow through the backend API, so server-side rules (validation, compliance, suppression) apply uniformly and the agent can't sidestep them.
    Alternatives: Direct DB writes would be faster but would move enforcement into the least-trusted component.
  3. Decision: Server-side compliance enforcement
    Why: The backend attaches the unsubscribe footer and blocks unsubscribed contacts on every send. Compliance therefore holds regardless of what the agent decides — it's a property of the system, not the prompt.
    Alternatives: Asking the agent to remember the rules — unacceptable for a legal requirement.
  4. Decision: Contract tests pinning every backend call
    Why: A silent key-casing mismatch (PascalCase vs snake_case) can make the backend ignore a field and behave wrongly. Tests assert the exact method, path, and JSON shape of each tool call so binding bugs fail loudly.
    Alternatives: Manual testing — too easy to miss a quietly-ignored field.

⚙️Implementation

Components / Modules

  • Scope-gated toolset (buildTools): Reads the bot's scopes; always registers read tools (kb_retrieve, crm_search_contacts, …); conditionally registers write tools (email_send, campaign_*, automation_*, crm write ops) only when the matching scope is held.
  • Reason→tool loop: Vercel AI SDK + Anthropic Claude; the model plans and calls tools from whatever toolset it was given — which is exactly the set its scopes permit.
  • Audit log (agent_actions): Every mutating tool records conversation_id, action, target, result, optional approver, and detail — a reviewable trail of what the agent actually did.
  • Backend compliance layer: Attaches the CAN-SPAM unsubscribe footer and refuses sends to unsubscribed contacts; the agent has no path around it.

              // buildTools() — a write tool is only added to the set when the bot holds the scope.
export function buildTools(ctx: ToolContext) {
  const canSend = ctx.scopes.includes("email:send");
  const canFire = ctx.scopes.includes("automation:fire");

  const tools: ToolSet = { kb_retrieve, crm_search_contacts /* read-only, always present */ };

  if (canSend) Object.assign(tools, { email_send });            // real single send
  if (canFire) Object.assign(tools, { automation_create, automation_enroll });
  // ...crm:write and email:blast gated the same way...
  return tools;                          // the model can only call what's registered
}
            

🛡️Security

Threat Model
  • Prompt injection coaxing a forbidden action: the tool isn't registered, so there's nothing to call
  • Over-permissioned bot token: scopes are minimal and explicit; capability tracks the token, not the prompt
  • Emailing unsubscribed contacts: blocked server-side regardless of agent intent
  • Silent backend-binding bug: contract tests pin exact request shapes to catch ignored fields

Controls Implemented

  • Least-privilege tool registration keyed on the bot JWT's scopes (crm:read/write, email:send/blast, automation:fire)
  • No direct database access — all writes go through the backend API
  • Server-side compliance (unsubscribe footer + suppression of unsubscribed contacts)
  • Audit logging of every mutating action to the agent_actions table
  • Contract tests pinning method/path/JSON shape for each tool call

Verification

  • Unit/contract tests assert each tool's exact backend request and reject wrong key casing
  • Read vs write tools verified to register only under the appropriate scopes

⚙️Operations

Observability

  • agent_actions provides a queryable record of every action, target, and result
  • Action results (success/failed) and error details captured for review

Incident Response

  • Suspected misuse: review agent_actions for the conversation; revoke or narrow the bot's scopes to immediately cut capability
  • Compromised bot token: rotate the service-account credentials; scopes bound the blast radius in the meantime

Cost Controls

  • Runs as a lightweight Node service under systemd on existing Hetzner infrastructure
  • Scheduled work (knowledge ingestion, daily planning, due-task runs) driven by systemd timers rather than a standing scheduler

📊Results

Outcomes

  • Authorization: The agent's real capabilities are bounded by least-privilege scopes — an unauthorized tool is unrepresentable, not just discouraged.
  • Accountability: Every state-changing action is recorded with its target and result, making autonomous behavior reviewable.
  • Compliance: CAN-SPAM footer and suppression are enforced server-side, so legal compliance doesn't depend on the model.
  • Reliability: Contract tests catch silent backend-binding regressions before they ship.

⚖️Tradeoffs

  • Routing everything through the backend (no direct DB) is slightly slower but keeps enforcement in the trusted layer
  • Scope-gating per tool means new capabilities require an explicit scope + registration step — friction by design

🚀Next Steps

  • Optional human-approval gating for the highest-impact tools (mass sends)
  • Per-action rate limits on top of scope gating