Se você tem 40 segundos
- Guardrails multi-tenant, ou seja, para serviços com vários clientes isolados entre si, reduzem risco quando vivem no prompt, mas instrução não é barreira determinística e não substitui autorização no executor.
- O ponto onde o limite vira controle é o executor de ferramenta, no seu código, antes do efeito acontecer.
- Três checagens formam o ponto de partida: lista explícita das ferramentas permitidas para cada tenant (allowlist), limite de valor por chamada, limite de taxa por conversa.
- Sem registro de negativa, auditoria interna e investigação de incidente ficam procurando agulha no log de aplicação.
- O produto deste artigo é um esqueleto ilustrativo de executor com guardrails multi-tenant, para você adaptar ao seu serviço.
A instrução que o modelo ignorou
Exemplo hipotético: um agente de atendimento em WhatsApp teria no prompt a frase “nunca emita reembolso acima de R$ 200 sem aprovação humana“. O agente emitiria assim mesmo. O cliente pediria duas vezes na mesma conversa, mudaria o enquadramento na segunda, e o modelo chamaria a ferramenta de reembolso com o valor cheio. O texto estaria lá, escrito, em maiúsculas. O efeito sairia do mesmo jeito.
Guardrails multi-tenant escritos no prompt são o aviso de “não entre” colada na porta do estoque. Escritos no executor, são a catraca que só gira com o crachá certo. O aviso depende de quem lê e da intenção de quem lê. A catraca depende do crachá, e quem emite o crachá é você. Escreva a regra onde a catraca está, no código que roda a ferramenta.

Por que o prompt não segura
A OWASP chama isso de Excessive Agency, o item LLM06 de 2025, e aponta três causas de raiz: funcionalidade demais, permissão demais, autonomia demais. Texto no prompt não resolve nenhuma das três de forma determinística. A mitigação recomendada é mediação completa: autorize a ação no sistema, depois da interação com o modelo.
A documentação da Anthropic sobre mitigação de jailbreak e injeção de prompt trata engenharia de prompt como uma camada entre várias, e pede privilégio mínimo para o acesso do modelo a dados e ações. A ideia é que uma injeção bem sucedida faça o menor estrago possível. Guardrails multi-tenant entram aí como camada, e a última barreira fica no seu código.
A especificação do MCP para ferramentas é mais seca.
| Ator | Ação | Propósito |
|---|---|---|
| Servidor | Validar toda entrada de ferramenta | Segurança e controle de acesso |
| Servidor | Limitar a taxa de invocação | Prevenção de abuso |
| Cliente | Registrar uso de ferramenta | Auditoria e rastreabilidade |
Eu tiro a ferramenta de reembolso da lista de qualquer tenant que não tenha limite de valor gravado no banco. Sem linha na tabela, sem ferramenta. Configuração ausente vira negativa, nunca vira padrão permissivo. Guardrails multi-tenant que herdam padrão permissivo abrem na primeira ferramenta nova.
A tabela de política por tenant
Guardrails multi-tenant começam por uma tabela. Uma linha por tenant e ferramenta, no seu banco, versionada, com quem alterou e quando. Essa tabela é a fonte de verdade.
| tenant_id | ferramenta | permitida | valor_max_brl | chamadas_max_conversa | exige_aprovacao |
|---|---|---|---|---|---|
| acme | consultar_pedido | sim | (não se aplica) | 20 | não |
| acme | emitir_reembolso | sim | 200,00 | 2 | nega acima do limite |
| acme | cancelar_assinatura | não | (não se aplica) | 0 | (não se aplica) |
| bravo | consultar_pedido | sim | (não se aplica) | 20 | não |
| bravo | emitir_reembolso | não | (não se aplica) | 0 | (não se aplica) |
A coluna exige_aprovacao diz “nega acima do limite” porque o esqueleto deste artigo não implementa fluxo de aprovação humana. Chamada acima do limite morre com negativa registrada. Aprovação humana pede uma máquina de estados própria, com pendência persistida e retomada, e isso fica fora do escopo daqui.
O tenant bravo não tem reembolso automático. O modelo pode pedir quantas vezes quiser, com o argumento que quiser. A chamada morre antes do efeito.
Amarre o tenant antes de olhar a política
Guardrails multi-tenant dependem de saber qual tenant está falando. O tenant vem do token da sua API, nunca do corpo da mensagem e nunca do argumento que o modelo escreveu. Se o modelo pudesse escolher o tenant, a tabela acima não valeria nada.
SENSITIVE_TOOLS = frozenset({"emitir_reembolso", "cancelar_assinatura"})
def resolve_tenant(request):
claims = verify_api_token(request.headers["authorization"])
return claims["tenant_id"]
def load_policy(policy_store, tenant_id):
rows = policy_store.fetch(tenant_id)
if not rows:
raise Denied("tenant_without_policy", tenant_id)
allowed = set()
for row in rows:
if not row.allowed:
continue
if row.tool in SENSITIVE_TOOLS and (row.max_value_brl is None
or row.max_calls is None):
raise Denied("tool_without_required_limits", row.tool)
allowed.add(row.tool)
return ToolPolicy(
allowed=frozenset(allowed),
max_value_brl={r.tool: r.max_value_brl for r in rows
if r.max_value_brl is not None},
max_calls_per_conversation={r.tool: r.max_calls for r in rows
if r.max_calls is not None},
)
policy_store é a sua tabela, com cache curto se o volume pedir. Política ausente cai em Denied, não em objeto vazio permissivo. Ferramenta sensível sem limite gravado também cai em Denied, e é aí que a opinião da seção anterior vira código. Os guardrails multi-tenant passam a valer no carregamento, antes de qualquer argumento do modelo.
Valide o argumento antes de aplicar limite
O MCP pede validação de entrada, e limite de valor não é validação. Guardrails multi-tenant sem schema barram o valor e deixam passar tipo errado. Argumento ausente, tipo errado ou campo extra tem que morrer antes de qualquer comparação numérica.
SCHEMAS = {
"emitir_reembolso": {
"valor_brl": {"type": Decimal, "required": True, "min": Decimal("0.01")},
"pedido_id": {"type": str, "required": True},
},
"consultar_pedido": {
"pedido_id": {"type": str, "required": True},
},
}
def validate_args(name, args):
schema = SCHEMAS.get(name)
if schema is None:
raise Denied("tool_without_schema", name)
extra = set(args) - set(schema)
if extra:
raise Denied("unexpected_argument", ",".join(sorted(extra)))
clean = {}
for field, spec in schema.items():
if field not in args:
if spec["required"]:
raise Denied("missing_argument", field)
continue
try:
value = spec["type"](str(args[field]))
except (InvalidOperation, ValueError):
raise Denied("bad_type", field)
if spec["type"] is Decimal:
try:
if not value.is_finite():
raise Denied("bad_value", field)
if "min" in spec and value < spec["min"]:
raise Denied("below_minimum", f"{field}={value}")
except InvalidOperation:
raise Denied("bad_value", field)
clean[field] = value
return clean
O is_finite está ali porque Decimal("NaN") e Decimal("Infinity") atravessam a conversão sem erro. NaN comparado com o mínimo pode levantar InvalidOperation, e erro interno no meio do guardrail vale menos que uma negativa explícita. Por isso a comparação numérica também vive dentro do tratamento.
validate_args devolve os argumentos limpos, e o resto do executor só trabalha com eles. Valor negativo e valor ausente viram negativa registrada, com a mesma trilha de qualquer outra regra.
Escreva as checagens de política
Sem framework, para deixar visível onde cada regra entra. A ordem dos guardrails multi-tenant importa: allowlist primeiro, valor depois, taxa por último. Chamada negada por allowlist não gasta quota da conversa.
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
@dataclass(frozen=True)
class ToolPolicy:
allowed: frozenset
max_value_brl: dict
max_calls_per_conversation: dict
class Denied(Exception):
def __init__(self, rule, detail):
super().__init__(rule)
self.rule = rule
self.detail = detail
def check_call(policy, counters, tenant_id, conversation_id, name, clean_args):
if name not in policy.allowed:
raise Denied("tool_not_allowed", name)
limit = policy.max_value_brl.get(name)
if limit is not None:
value = clean_args["valor_brl"]
if value > limit:
raise Denied("value_over_limit", f"{value} > {limit}")
quota = policy.max_calls_per_conversation.get(name)
if quota is not None:
key = (tenant_id, conversation_id, name)
used = counters.increment(key)
if used > quota:
raise Denied("rate_over_limit", f"{used} > {quota}")
A chave da quota carrega tenant_id na frente. ID de conversa raramente é único entre clientes, e sem o tenant na chave um cliente consome ou trava a quota do outro. counters.increment precisa ser atômico no seu Redis ou no seu Postgres, porque duas chamadas do mesmo turno podem ser executadas concorrentemente quando o modelo emite ferramentas em paralelo. Contador só em memória quebra em implantação distribuída.
O teste percorre os três caminhos, na ordem que o executor promete:
def test_check_call_percorre_allowlist_valor_e_quota():
policy = ToolPolicy(
allowed=frozenset({"emitir_reembolso"}),
max_value_brl={"emitir_reembolso": Decimal("200.00")},
max_calls_per_conversation={"emitir_reembolso": 2},
)
counters = FakeCounters()
args = {"valor_brl": Decimal("10.00"), "pedido_id": "p1"}
with pytest.raises(Denied) as nao_permitida:
check_call(policy, counters, "acme", "c1", "cancelar_assinatura", args)
assert nao_permitida.value.rule == "tool_not_allowed"
assert counters.calls == 0
acima = {"valor_brl": Decimal("480.00"), "pedido_id": "p1"}
with pytest.raises(Denied) as passou_do_valor:
check_call(policy, counters, "acme", "c1", "emitir_reembolso", acima)
assert passou_do_valor.value.rule == "value_over_limit"
assert counters.calls == 0
check_call(policy, counters, "acme", "c1", "emitir_reembolso", args)
check_call(policy, counters, "acme", "c1", "emitir_reembolso", args)
with pytest.raises(Denied) as passou_da_quota:
check_call(policy, counters, "acme", "c1", "emitir_reembolso", args)
assert passou_da_quota.value.rule == "rate_over_limit"
O assert counters.calls == 0 é a parte que costuma quebrar em refatoração. Ele fixa que negativa barata não consome quota.
Ligue o executor no despacho
O despacho junta os guardrails multi-tenant em uma ordem só. O retorno da negativa segue o formato de erro de ferramenta do MCP, com isError verdadeiro. A reserva idempotente, o registro atômico de que aquela operação já começou ou terminou, vem antes das checagens que gastam quota. Tentativa repetida depois de timeout é a mesma operação, e não pode gerar dois estornos nem consumir a quota duas vezes.
NEGATIVA = "call refused by tenant policy"
EM_ANDAMENTO = "call already in progress, retry with the same key"
def run_tool(deps, request, call):
tenant_id = resolve_tenant(request)
ledger_key = f"{tenant_id}:{call.name}:{call.idempotency_key}"
args_digest = canonical_digest(call.args)
reserved = deps.ledger.reserve(ledger_key, args_digest, owner=call.request_id)
if reserved.args_digest != args_digest:
return {
"content": [{"type": "text", "text": "idempotency key reused"}],
"isError": True,
}
if reserved.settled:
return reserved.result
if reserved.owner != call.request_id:
return {
"content": [{"type": "text", "text": EM_ANDAMENTO}],
"isError": True,
"retryable": True,
}
try:
policy = load_policy(deps.policy_store, tenant_id)
clean_args = validate_args(call.name, call.args)
check_call(policy, deps.counters, tenant_id,
call.conversation_id, call.name, clean_args)
except Denied as denied:
deps.audit.write({
"tenant_id": tenant_id,
"conversation_id": call.conversation_id,
"tool": call.name,
"rule": denied.rule,
"detail": denied.detail,
"args_hmac": hmac_args(deps.audit_key, call.args),
"at": now_utc(),
})
refusal = {
"content": [{"type": "text", "text": NEGATIVA}],
"isError": True,
}
deps.ledger.settle(ledger_key, refusal)
return refusal
return deps.ledger.settle_with(
ledger_key,
lambda: deps.tools[call.name](tenant_id=tenant_id, **clean_args),
)
O texto da negativa é curto e não instrui o modelo. A Anthropic recomenda não inserir instruções no resultado da ferramenta, porque o modelo pode ignorá-las por considerá-las pouco confiáveis. A orientação de “peça aprovação humana agora” vai no turno de usuário seguinte, ou na mensagem de sistema, quando o modelo tiver suporte a isso.
Como a reserva idempotente evita repetição
O ledger, o registro persistente das operações reservadas e concluídas, tem três estados por chave: livre, reservada e liquidada. reserve é uma operação atômica de inserção condicional, tipo INSERT ... ON CONFLICT DO NOTHING seguido de leitura, ou SET NX no Redis. Quem cria a linha vira proprietário e segue. Quem chega depois lê o estado e para.

Sem essa posse exclusiva, duas requisições com a mesma chave chegariam juntas ao check_call e ao estorno. A garantia de “não duplica” vale enquanto o armazenamento fizer a exclusão atômica até a liquidação. Ledger em memória quebra essa promessa em vários processos.
O contrato da repetição com guardrails multi-tenant fica em três linhas. Chave reservada por outro dono devolve EM_ANDAMENTO, sem tocar em check_call, sem incrementar quota e sem chamar a ferramenta. Chave já liquidada devolve o resultado gravado, negativa incluída. Chave livre segue o fluxo inteiro. Repetição idempotente não consome quota nova, e o cliente que perdeu a resposta por timeout não pode ser punido pelo próprio timeout.
def test_duas_chamadas_concorrentes_com_a_mesma_chave():
deps = FakeDeps(tools={"emitir_reembolso": conta_execucoes})
call_a = Call(name="emitir_reembolso", idempotency_key="k1",
request_id="req-a", args=ARGS_VALIDOS)
call_b = replace(call_a, request_id="req-b")
with ThreadPoolExecutor(max_workers=2) as pool:
futuro_a = pool.submit(run_tool, deps, REQUEST, call_a)
futuro_b = pool.submit(run_tool, deps, REQUEST, call_b)
resultados = [futuro_a.result(), futuro_b.result()]
assert conta_execucoes.chamadas == 1
assert sum(r.get("retryable", False) for r in resultados) == 1
assert deps.counters.calls == 1
A chave e o digest dos argumentos
A chave do ledger junta tenant, ferramenta e a chave que o cliente mandou, com unicidade garantida no armazenamento. Chave só com idempotency_key colide entre clientes, e colisão aqui devolve o resultado de outra chamada ou engole uma operação legítima.
Junto da reserva vai args_digest, um hash canônico dos argumentos daquela chamada. Cliente que reaproveita a mesma chave para outro pedido ou outro valor recebe recusa, em vez do resultado antigo. Sem esse hash, o executor trataria duas operações diferentes como idênticas e devolveria um estorno de R$ 20,00 para quem pediu R$ 480,00. canonical_digest precisa de serialização determinística: chaves ordenadas, número em forma normalizada, sem espaço variável.
Esse bloco é esqueleto ilustrativo, e a ordem dos guardrails multi-tenant é o que costuma sair errado. O que você conecta na sua infra, deps.counters, deps.ledger, deps.audit, deps.tools, canonical_digest, hmac_args, now_utc e verify_api_token, é apenas as peças que encaixam nessa sequência.
| Peça | Variável |
|---|---|
| 1 | deps.counters |
| 2 | deps.ledger |
| 3 | deps.audit |
| 4 | deps.tools |
| 5 | canonical_digest |
| 6 | hmac_args |
| 7 | now_utc |
| 8 | verify_api_token |
Guardrail antes da chamada custa menos
Existe um argumento de custo que independe de segurança. O guia de guardrails do Agents SDK descreve dois modos para guardrail de entrada. No modo paralelo, o agente pode já ter consumido tokens ou executado ferramentas quando o tripwire dispara. No modo bloqueante, run_in_parallel=False, o agente não começa. O mesmo guia define guardrails por chamada de ferramenta: bloqueia a execução, substitui a saída ou dispara tripwires. Se você já usa esse SDK, essa é a porta pronta para colar os guardrails multi-tenant acima.
Faça a conta com os seus números. Os abaixo são exemplo para mostrar a forma da conta, não medição minha:
| variavel | exemplo | de onde tirar |
|---|---|---|
| conversas com pedido de reembolso no mes | 3.000 | seu banco |
| fracao que passa do limite do tenant | 4% | seu banco |
| valor medio do reembolso indevido | R$ 180,00 | seu banco |
| custo mensal do agente em chamadas ao provedor | US$ 900,00 | fatura do provedor |
Nesse exemplo, 120 reembolsos indevidos somam R$ 21.600,00 no mês. Guardrails multi-tenant custam linhas de Python e nenhum token do provedor. Faturar em real e pagar em dólar aperta a margem, mais do que uma redução moderada no custo por token. Com os seus números, a conta pode dar outro resultado.
O log de negativa é o produto de auditoria
Esse registro não existe para debugar. Ele existe para responder pergunta de auditoria interna com evidência, e para alimentar o painel que o time de privacidade abre quando investiga incidente. Cada negativa dos guardrails multi-tenant vira uma linha com campos fixos.
| campo | exemplo | para que serve |
|---|---|---|
tenant_id |
acme | separa o painel por cliente |
conversation_id |
c_8f21 | liga a negativa ao atendimento |
tool |
emitir_reembolso | mostra qual capacidade foi tentada |
rule |
value_over_limit | diz qual regra barrou |
detail |
480,00 > 200,00 | prova o limite aplicado |
args_hmac |
hmac:9c1a… | correlaciona entradas idênticas |
at |
2026-08-29T14:03:11Z | ordena a série temporal |
O args_hmac é deliberado. Argumentos de ferramenta em atendimento podem carregar CPF, endereço ou número de cartão truncado. Guardar o argumento cru multiplica o dado pessoal por cada tentativa negada, dentro de um sistema de log que raramente tem retenção curta. Um HMAC sobre argumentos com uma ordem padronizada, com chave protegida, permite correlacionar entradas idênticas e reduz a exposição do argumento bruto.
Isso não transforma o registro em dado anônimo.
| Aspecto | Detalhe |
|---|---|
| Natureza do dado | Pseudonimizado (não anônimo) |
| Risco principal | Força bruta contra chaves de baixa entropia |
| Condicionante do risco | Vazamento da chave ou uso como oráculo |
| Identificador derivado | Permite associação, exige tratamento como pseudonimizado |
| Ações necessárias | Retenção definida e classificação validada pelo jurídico |
Três alertas simples valem o painel: negativas por tenant por hora, mesma ferramenta negada três vezes na mesma conversa, e regra tool_not_allowed disparando em tenant que nunca deveria tentar aquilo.
O terceiro pode indicar injeção indireta, com conteúdo de fora empurrando o modelo para uma capacidade que ele não tem. Antes de concluir isso, cheque erro de configuração da allowlist, roteamento de chamada para o tenant errado e mudança recente no prompt.
Comece pela ferramenta mais cara
Segunda-feira, abra a lista de ferramentas do seu agente e ordene por reversibilidade. A que gera dinheiro saindo ou dado saindo fica no topo. Escreva a linha dela na tabela de política, para todos os tenants, com valor máximo e quota por conversa. As outras ferramentas podem esperar a próxima semana.
Mantenha a orientação no prompt como defesa em profundidade, e trate o executor em Python como a barreira autoritativa. Guardrails multi-tenant no prompt continuam reduzindo tentativa; o Python é quem não muda de ideia quando o cliente insiste.
Fontes





Deixe um comentário