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:
- HTTP and browser responses start as UNTRUSTED_EXTERNAL unless the source is explicitly trusted.
- Authenticated records receive TENANT_PRIVATE and the tenant ID.
- Tokens, cookies, and private keys receive SECRET and should normally be non-exportable.
- User-submitted text receives USER_INPUT, even when it looks like a system instruction.
- 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:
- Inventory every ingress adapter and label at the boundary.
- Inventory every sink, including logs, traces, caches, browser downloads, and notifications.
- Define inheritance rules for model output and transformed data.
- Make secrets non-exportable by default.
- Add atomic per-run and per-tenant reservations.
- Recheck policy, credentials, approval, and labels at dispatch.
- Give every external effect a stable operation ID.
- Test redirects, retries, timeouts, races, and unavailable evidence storage.
- Alert on denied flows and repeated UNKNOWN outcomes.
- 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)