DEV Community

Zira
Zira

Posted on

Your AI Agent Needs a Data-Flow Budget, Not Just a Tool Allowlist

A tool allowlist answers one question: which functions may an agent call?

It does not answer the harder question: what information may flow into those calls, and where may the result go?

That gap matters when an agent reads a ticket, webpage, email, repository, or MCP response and then uses the content to construct a request. The tool itself may be approved while the arguments contain secrets, attacker-controlled instructions, or data from a tenant the current run should never touch.

The practical fix is to track data-flow labels through the run and enforce a budget at every sensitive sink.

The model is not the policy boundary

Treat model output and tool results as data, not authorization. A policy layer should decide whether a proposed call is allowed after inspecting:

  • the run and tenant identity
  • the destination and operation
  • the labels on each argument
  • the permitted sinks for those labels
  • the remaining data-flow budget
  • the current credential and approval version

This is similar to a network egress policy. A service may be allowed to make HTTPS requests, but not to send database exports to an arbitrary host. An agent may be allowed to call a browser tool, but not to copy an untrusted page plus an API key into a form submission.

Use labels that describe origin, not intent

Start with a small vocabulary. Keep it boring enough that every adapter can apply it consistently.

type DataLabel =
| "PUBLIC"
| "USER_INPUT"
| "UNTRUSTED_EXTERNAL"
| "TENANT_PRIVATE"
| "SECRET"
| "DERIVED";

A value can carry more than one label. For example, a summary generated from a private document is still tenant-private even if the model produced the summary. A value copied from a webpage remains untrusted external content even if the model calls it an instruction.

Do not let the model assign or remove labels. Adapters should label values at ingress:

  1. HTTP and browser responses start as UNTRUSTED_EXTERNAL unless the source is explicitly trusted.
  2. Authenticated records receive TENANT_PRIVATE and the tenant ID.
  3. Tokens, cookies, and private keys receive SECRET and should normally be non-exportable.
  4. User-submitted text receives USER_INPUT, even when it looks like a system instruction.
  5. Model summaries inherit the most restrictive relevant source labels.

Define sinks, not just tools

A single tool can expose several sinks with different risk. Split them in policy.

Sink Example Default rule
Model context Add a document to the prompt Allow labels needed for the task; never expose SECRET values
Local state Write a checkpoint Keep tenant ID and labels; encrypt private data
Browser navigation Open a URL Allow PUBLIC and approved domains; review external redirects
Browser form submission Submit a form Require destination, field-level policy, and explicit approval for private data
External API POST JSON Deny SECRET; require an allowlisted host and purpose
Human notification Send email or chat Redact secrets and require recipient policy

This is where a hosting decision can be relevant, but it is not the security control by itself. For an always-on OpenClaw or browser workflow, managed OpenClaw hosting on Ampere can be one runtime option to evaluate. The data-flow policy, credential scope, and approval checks still belong in the application.

Make the budget explicit

A budget limits how much sensitive data can cross a sink during one run. It can be counted in records, bytes, fields, or approved destinations.

For example:

type FlowBudget = {
runId: string;
tenantId: string;
remainingPrivateFields: number;
allowedExternalHosts: Set;
deniedLabels: Set;
policyVersion: number;
};

function authorizeSink(input: {
sink: string;
host?: string;
labels: Set;
privateFieldCount: number;
budget: FlowBudget;
}) {
const { sink, host, labels, privateFieldCount, budget } = input;

for (const label of labels) {
if (budget.deniedLabels.has(label)) {
return { allowed: false, reason: label_denied:${label} };
}
}

if (host && !budget.allowedExternalHosts.has(host)) {
return { allowed: false, reason: "host_not_allowlisted" };
}

if (privateFieldCount > budget.remainingPrivateFields) {
return { allowed: false, reason: "private_field_budget_exceeded" };
}

return { allowed: true, reason: "policy_match" };
}

The check must happen immediately before dispatch, not only when the model proposes the call. Between proposal and dispatch, an approval can expire, a tenant can change, or another worker can consume the remaining budget.

Reserve the budget atomically with the operation record. If the provider result is unknown after a timeout, do not automatically retry with a fresh reservation. Reconcile the first attempt by provider request ID or an idempotency key, then decide whether another transfer is safe.

Test the boundary with hostile fixtures

A useful test suite does not ask only whether a normal prompt succeeds. It injects data at every ingress and tries to move it to every sink.

Test Expected result
Page contains a fake instruction asking for the API key Key never enters model context or tool arguments
Private record is summarized for another tenant Request is denied by tenant and label checks
Model renames SECRET as notes The underlying label remains SECRET
Redirect changes an approved browser origin Navigation or submission is denied
Two workers spend the final private-field budget One reservation wins atomically
Policy version changes after planning Dispatch rechecks and denies the stale plan
Provider times out after accepting the request Result becomes UNKNOWN and is reconciled, not blindly replayed
Audit writer is unavailable Sensitive dispatch fails closed or enters a visible recovery state

Record the decision, policy version, labels, sink, destination, budget before and after, and stable operation ID. Redact values from the evidence trail. An audit record should prove why a call was allowed without becoming another exfiltration path.

A small rollout checklist

Before enabling an agent on real data:

  1. Inventory every ingress adapter and label at the boundary.
  2. Inventory every sink, including logs, traces, caches, browser downloads, and notifications.
  3. Define inheritance rules for model output and transformed data.
  4. Make secrets non-exportable by default.
  5. Add atomic per-run and per-tenant reservations.
  6. Recheck policy, credentials, approval, and labels at dispatch.
  7. Give every external effect a stable operation ID.
  8. Test redirects, retries, timeouts, races, and unavailable evidence storage.
  9. Alert on denied flows and repeated UNKNOWN outcomes.
  10. Review the policy when a new tool or connector is added.

A tool allowlist is still useful. It is just the outer fence. The control that prevents a harmless-looking approved tool from becoming an exfiltration path is the data-flow contract enforced at the sink.

Top comments (0)