DevOps ·

Securing Agentic AI in Production: Permission Scoping, M2M Traffic Monitoring, and Preventing the Next Kiro-Style Outage

47% of organizations have delayed AI agent releases due to API security concerns. This guide covers the concrete engineering patterns that fix the problem: permission scope boundaries, M2M traffic observability, and human approval gates for high-risk agent actions.

Securing Agentic AI in Production: Permission Scoping, M2M Traffic Monitoring, and Preventing the Next Kiro-Style Outage

In December 2025, an Amazon AI coding assistant called Kiro deleted and recreated a live production environment. The outage lasted 13 hours. The agent had inherited the on-call engineer’s elevated IAM permissions and bypassed the two-person approval gate that was supposed to catch exactly this kind of destructive action. Amazon responded by mandating peer reviews for all Kiro-assisted infrastructure operations. Roughly 1,500 engineers signed an internal petition against that mandate.

Three months later, an internal Meta AI agent exposed sensitive company and user data for approximately two hours. Meta classified it Sev-1.

These are not edge cases. According to the Salt Security 1H 2026 report, 47% of organizations have delayed production releases of AI agents due to API security concerns. 48.9% have no visibility into machine-to-machine traffic at all. 92% lack advanced security maturity for agentic environments, and 31% cannot determine whether they have already been breached by an agent.

The common thread in both incidents is the same: permission inheritance without scope boundaries. An agent authenticated as a human, received that human’s full permission set, and then took actions no human would have intended to authorize.

This article covers the engineering patterns that prevent these failures: how to define agent-specific permission scopes, how to build M2M traffic observability, and how to implement human approval gates that actually work under production load.

The Core Failure Mode: Unbounded Permission Inheritance

Most teams bootstrap agent authentication the same way: give the agent a service account, and give that service account the same permissions a developer would need to test things manually. This works fine until the agent does something irreversible.

The problem is structural. Human permissions are designed around intent: a senior engineer has broad access because she makes contextual judgments about when to use it. An agent has no such context. It uses whatever permissions it has, because that is the simplest way to accomplish the task in its context window.

The fix is not fewer permissions globally. It is scoped permissions per agent, per task, with explicit ceilings.

Define agent scopes as a closed set:

// types/agent-permissions.ts

export type AgentScope =
  | "read:code"
  | "read:logs"
  | "read:metrics"
  | "write:code"
  | "write:config"
  | "deploy:staging"
  | "deploy:production"
  | "infra:read"
  | "infra:modify"
  | "infra:destroy";

export interface AgentIdentity {
  agentId: string;
  agentType: "coding-assistant" | "deploy-bot" | "observability-bot" | "general";
  scopes: AgentScope[];
  maxRiskLevel: "low" | "medium" | "high";
  requiresHumanApproval: AgentScope[];
}

// Each agent class gets a fixed scope ceiling at registration time.
// Nothing at runtime can elevate it.
export const AGENT_REGISTRY: Record<string, AgentIdentity> = {
  "kiro-coding-assistant": {
    agentId: "kiro-coding-assistant",
    agentType: "coding-assistant",
    scopes: ["read:code", "write:code", "read:logs"],
    maxRiskLevel: "medium",
    requiresHumanApproval: ["write:code"],
  },
  "deploy-bot": {
    agentId: "deploy-bot",
    agentType: "deploy-bot",
    scopes: ["read:code", "read:logs", "deploy:staging", "deploy:production"],
    maxRiskLevel: "high",
    requiresHumanApproval: ["deploy:production"],
  },
};

The key property is requiresHumanApproval: a list of scopes that require an out-of-band human confirmation before the agent can proceed, regardless of how the request was initiated. Kiro had infra:destroy available with no such gate. It should never have had that scope at all.

Agent Permission Middleware

Once scopes are defined, enforce them at the boundary where agent requests enter your system. This is not just an authorization check. It is the point where you emit audit events and evaluate whether a human gate is needed.

// middleware/agent-auth.ts
import { Request, Response, NextFunction } from "express";
import { AgentIdentity, AgentScope, AGENT_REGISTRY } from "../types/agent-permissions";
import { emitAgentAuditEvent } from "../audit/emitter";
import { createApprovalRequest } from "../approvals/workflow";

export interface AgentRequest extends Request {
  agent?: AgentIdentity;
  requestedScope?: AgentScope;
}

function extractAgentToken(req: Request): { agentId: string; scope: AgentScope } | null {
  const auth = req.headers["x-agent-token"];
  if (typeof auth !== "string") return null;

  try {
    // In production, verify a signed JWT here. The payload carries agentId and scope.
    const payload = verifyAgentJwt(auth);
    return { agentId: payload.sub, scope: payload.scope as AgentScope };
  } catch {
    return null;
  }
}

export function requireAgentScope(requiredScope: AgentScope) {
  return async (req: AgentRequest, res: Response, next: NextFunction) => {
    const tokenData = extractAgentToken(req);
    if (!tokenData) {
      return res.status(401).json({ error: "missing or invalid agent token" });
    }

    const identity = AGENT_REGISTRY[tokenData.agentId];
    if (!identity) {
      await emitAgentAuditEvent({
        agentId: tokenData.agentId,
        scope: requiredScope,
        action: "auth_rejected",
        reason: "unknown_agent",
        timestamp: new Date().toISOString(),
      });
      return res.status(403).json({ error: "unknown agent identity" });
    }

    if (!identity.scopes.includes(requiredScope)) {
      await emitAgentAuditEvent({
        agentId: identity.agentId,
        scope: requiredScope,
        action: "auth_rejected",
        reason: "scope_not_granted",
        timestamp: new Date().toISOString(),
      });
      return res.status(403).json({ error: `scope '${requiredScope}' not granted` });
    }

    // Check if this scope requires human approval before proceeding.
    if (identity.requiresHumanApproval.includes(requiredScope)) {
      const approvalToken = req.headers["x-approval-token"];
      if (typeof approvalToken !== "string") {
        // No pre-approved token: open an approval request and pause the operation.
        const approvalId = await createApprovalRequest({
          agentId: identity.agentId,
          scope: requiredScope,
          context: {
            path: req.path,
            method: req.method,
            body: req.body,
          },
        });

        await emitAgentAuditEvent({
          agentId: identity.agentId,
          scope: requiredScope,
          action: "approval_requested",
          approvalId,
          timestamp: new Date().toISOString(),
        });

        return res.status(202).json({
          status: "pending_approval",
          approvalId,
          message: "Human approval required. Retry with x-approval-token once approved.",
        });
      }

      const approved = await verifyApprovalToken(approvalToken, {
        agentId: identity.agentId,
        scope: requiredScope,
      });

      if (!approved) {
        await emitAgentAuditEvent({
          agentId: identity.agentId,
          scope: requiredScope,
          action: "auth_rejected",
          reason: "invalid_approval_token",
          timestamp: new Date().toISOString(),
        });
        return res.status(403).json({ error: "invalid or expired approval token" });
      }
    }

    req.agent = identity;
    req.requestedScope = requiredScope;

    await emitAgentAuditEvent({
      agentId: identity.agentId,
      scope: requiredScope,
      action: "auth_granted",
      timestamp: new Date().toISOString(),
    });

    next();
  };
}

The HTTP 202 response is intentional. When an agent hits a scope that requires approval, the operation is not rejected. It is suspended. The agent receives an approvalId it can poll or discard. A human reviewer sees the request in the approval dashboard and either approves or denies it. If approved, the approval system issues a short-lived token the agent attaches on retry. The original request proceeds.

This pattern prevents the bypass that occurred in the Kiro incident. Even if the agent’s IAM role had elevated cloud permissions, the application layer would not have executed the destructive call without a valid approval token. Defense in depth: both layers need to fail for the action to proceed.

M2M Traffic Observability

The 48.9% blindness figure is the number that should worry security teams most. You cannot audit what you cannot see, and agent traffic looks nothing like human traffic. No session cookies, no browser fingerprints, no natural pauses between requests. An agent can issue hundreds of API calls per second, all authenticated, all appearing legitimate in access logs.

The standard access log is not enough. You need a structured M2M event stream that captures agent identity, scope, call graph position, and elapsed time per operation.

// audit/emitter.ts
import { Pool } from "pg";

export interface AgentAuditEvent {
  eventId: string;
  agentId: string;
  scope: string;
  action: "auth_granted" | "auth_rejected" | "approval_requested" | "operation_completed" | "operation_failed";
  approvalId?: string;
  reason?: string;
  durationMs?: number;
  targetResource?: string;
  timestamp: string;
  traceId?: string;  // Links agent calls across a multi-step task
}

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

// Table DDL (run once):
// CREATE TABLE agent_audit_log (
//   event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
//   agent_id TEXT NOT NULL,
//   scope TEXT NOT NULL,
//   action TEXT NOT NULL,
//   approval_id UUID,
//   reason TEXT,
//   duration_ms INTEGER,
//   target_resource TEXT,
//   trace_id TEXT,
//   created_at TIMESTAMPTZ NOT NULL DEFAULT now()
// );
// CREATE INDEX ON agent_audit_log (agent_id, created_at DESC);
// CREATE INDEX ON agent_audit_log (trace_id) WHERE trace_id IS NOT NULL;
// REVOKE UPDATE, DELETE ON agent_audit_log FROM app_role;

export async function emitAgentAuditEvent(event: Omit<AgentAuditEvent, "eventId">): Promise<void> {
  await pool.query(
    `INSERT INTO agent_audit_log
      (agent_id, scope, action, approval_id, reason, duration_ms, target_resource, trace_id, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
    [
      event.agentId,
      event.scope,
      event.action,
      event.approvalId ?? null,
      event.reason ?? null,
      event.durationMs ?? null,
      event.targetResource ?? null,
      event.traceId ?? null,
      event.timestamp,
    ]
  );
}

The REVOKE UPDATE, DELETE at the table level is important. Audit logs are only trustworthy if they are append-only. An agent that gains write access to its own audit trail can erase evidence of what it did. Grant the application role INSERT and SELECT, nothing else.

The traceId field is what makes M2M traffic auditable at the task level. When an agent begins a multi-step workflow (read file, analyze, write back, deploy), stamp every audit event with the same trace ID. You then have a full reconstruction of the call chain for any incident post-mortem.

Detecting Anomalous Agent Behavior

Structured audit logs enable anomaly detection that access logs cannot. A few queries that belong in your observability stack:

// monitoring/agent-anomalies.ts

interface AgentAnomalyReport {
  agentId: string;
  windowStart: Date;
  windowEnd: Date;
  totalCalls: number;
  rejectedCalls: number;
  rejectionRate: number;
  scopesAttempted: string[];
  unscopedAttempts: string[];  // scopes attempted but not granted
}

export async function computeAgentAnomalyReport(
  pool: Pool,
  agentId: string,
  windowMinutes: number = 60
): Promise<AgentAnomalyReport> {
  const windowStart = new Date(Date.now() - windowMinutes * 60 * 1000);

  const result = await pool.query<{
    total_calls: string;
    rejected_calls: string;
    scopes_attempted: string[];
    unscoped_attempts: string[];
  }>(
    `SELECT
       COUNT(*) AS total_calls,
       COUNT(*) FILTER (WHERE action = 'auth_rejected') AS rejected_calls,
       ARRAY_AGG(DISTINCT scope) AS scopes_attempted,
       ARRAY_AGG(DISTINCT scope) FILTER (WHERE action = 'auth_rejected' AND reason = 'scope_not_granted') AS unscoped_attempts
     FROM agent_audit_log
     WHERE agent_id = $1
       AND created_at >= $2`,
    [agentId, windowStart]
  );

  const row = result.rows[0];
  const total = parseInt(row.total_calls, 10);
  const rejected = parseInt(row.rejected_calls, 10);

  return {
    agentId,
    windowStart,
    windowEnd: new Date(),
    totalCalls: total,
    rejectedCalls: rejected,
    rejectionRate: total > 0 ? rejected / total : 0,
    scopesAttempted: row.scopes_attempted ?? [],
    unscopedAttempts: row.unscoped_attempts ?? [],
  };
}

// Alert thresholds you should wire to PagerDuty or Slack:
// - rejectionRate > 0.05 (5% rejected calls in a window = agent is probing)
// - unscopedAttempts.length > 0 (any attempt on a scope not granted = immediate alert)
// - totalCalls > 500 in 10 minutes for a single agent (runaway loop)

The unscopedAttempts alert is the most important. An agent attempting scopes it was never granted either has a bug in its instruction set or has been prompt-injected. Either case warrants immediate investigation. Do not let this be a low-priority notification.

Tradeoffs

ApproachLatency impactOps overheadBlast radius reductionWhen to use
Scope registry at startupNone (in-memory)LowHigh (no runtime elevation possible)Always. This is the baseline.
Per-request audit write (sync)5-20ms per callMedium (DB writes)None (observability only)Default for most production deployments.
Per-request audit write (async)~0msLowNone (events may lag)Only if latency budget is extremely tight. Accept log delay.
Human approval gate (202 pattern)Seconds to hoursHigh (human in loop)Very high for destructive opsAny irreversible infrastructure action, any prod deployment.
Anomaly detection via DB queriesNone (background)Medium (query scheduling)Medium (detect and alert, not prevent)All environments. Run every 5-10 minutes.

The 202 approval gate has the highest operational overhead. In practice, teams that have shipped this pattern find that most agents never trigger it, because well-scoped agents do not request destructive operations. The gate is expensive to implement and almost never fires. That is the point.

Production Considerations

Short-lived approval tokens matter. An approval token that does not expire can be replayed later by a compromised agent. Issue tokens with a 5-minute TTL and bind them to the specific agentId, scope, and target resource. If the agent retries after expiry, it opens a new approval request.

Scope inheritance at the orchestration layer. If you are using LangGraph or a similar multi-agent framework, the child agent’s scope ceiling should be the intersection of the parent’s scopes and the child’s own granted scopes. Never let orchestration expand scope. A coding agent spawning a sub-agent to run a deployment should not be able to grant that sub-agent deploy:production if the coding agent does not have it.

Treat 31% breach-blindness as your current state. The Salt report number is an industry average. Until you have structured M2M audit logs running, assume you are in that 31%. The first step is not anomaly detection, it is log emission. You cannot build alerts on top of data that does not exist yet.

Legacy security tools do not cover this. Only 23.5% of security leaders find their existing tools effective for agentic environments. WAFs and API gateways that inspect HTTP payloads are looking at the wrong layer. Agent-to-agent calls are authenticated, use valid tokens, and conform to your API schema. The dangerous part is the semantic intent of the call, which requires scope-aware middleware, not packet inspection.

The Meta incident timeline. Two hours of data exposure before detection suggests M2M traffic was either not being logged or logs were not being watched. A 60-second anomaly detection window with an alert on any auth rejection in the read:user_data scope would have caught the breach far earlier. The fix is operational: wire audit queries to alerting before you ship the agent, not after an incident forces you to.

Scope creep in long-running agents. An agent that starts with minimal scopes may need more over time as its capabilities expand. Resist the temptation to add scopes to the registry without a review cycle. Treat the agent permission registry as you would a Terraform plan: every change should be reviewed, versioned, and tied to a specific capability requirement.

What the Kiro Mandate Got Wrong

Amazon’s response to the December incident was to require human review of every Kiro-assisted operation. That is a blunt instrument, and 1,500 engineers were right to push back. Requiring approval for every action defeats the purpose of having an agent. The correct response is to require approval only for actions above a risk threshold, and to define that threshold in code, not in policy documents.

The scope registry and approval gate pattern described here would have prevented the December incident with zero change to the developer workflow for low-risk operations. The agent deletes a comment in a config file? No gate. The agent calls infra:destroy on a production resource? Hard stop, approval required, and it should not have had that scope in the first place.

Agentic AI in production is not inherently dangerous. Unbounded permission inheritance is. Fix the permission model and you remove most of the risk without removing the productivity.

The 78.6% of security leaders reporting increased executive scrutiny are responding to real incidents, not hypothetical threat models. The engineering response should be proportionate and precise: scoped identities, append-only audit logs, and automated anomaly detection that fires before two hours of data exposure become the floor.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
DevOps ·

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code

A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
DevOps ·

How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container

A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
DevOps ·

How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop

A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
DevOps ·

How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution

A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.