dcp_ai.fastapi — middleware para FastAPI¶
Tradução informativa da versão em inglês, atualizada em 28/10/2026. Em caso de divergência, prevalece a versão em inglês. Identificadores, comandos e blocos de código são mantidos sem tradução.
Middleware de verificação do DCP para FastAPI. Verifica automaticamente os Signed Bundles e fornece injeção de dependências para as rotas protegidas.
Especificações do DCP suportadas¶
Do DCP-01 ao DCP-09, de forma parcial: a verificação de bundles e a admissão como descritas abaixo, e alguns campos do DCP-05 e do DCP-09 (lifecycle_state, mandate_id); não é uma implementação completa dessas especificações.
Instalação¶
Publicado no PyPI (26/10/2026). O dcp-ai 2.10.0 inclui a implementação descrita aqui. Atualize da 2.9.0 e renove seu ambiente ou arquivo de lock. Veja as notas de Python.
A versão 2.9.0 não verifica assinaturas V2; não a use para verificar V2. A 2.10.0 inclui o verificador corrigido, confiança no principal, strict-v1, orçamentos de corpo, supervisão de fontes de confiança/revogação e tratamento de desconexões. A admissão não autoriza uma ação.
Para desenvolvimento do código-fonte:
git clone https://github.com/dcp-ai-protocol/dcp-ai.git
pip install -e "/path/to/dcp-ai/sdks/python[fastapi]"
Início rápido¶
from fastapi import FastAPI, Depends
from dcp_ai.fastapi import DCPVerifyMiddleware, require_dcp, DCPAgentContext
app = FastAPI()
# Add verification middleware
app.add_middleware(DCPVerifyMiddleware, require_bundle=False)
# Public route (no bundle required)
@app.get("/health")
async def health():
return {"ok": True}
# Protected route (requires a verified bundle)
@app.post("/api/action")
async def agent_action(agent: DCPAgentContext = Depends(require_dcp)):
return {
"agent_id": agent.agent_id,
"human_id": agent.human_id,
"capabilities": agent.capabilities,
"risk_tier": agent.risk_tier,
}
Enviar um bundle¶
Um bundle V2 híbrido tem cerca de 33 KB, mais do que os limites de cabeçalhos da maioria dos servidores e proxies: envie-o no corpo JSON. O cabeçalho é para os bundles que cabem em todos os limites do caminho. A requisição com corpo abaixo é um modelo: {...} representa um bundle assinado real.
# Via header
curl -X POST http://localhost:8000/api/action \
-H "Content-Type: application/json" \
-H "x-dcp-bundle: $(cat signed_bundle.json)" \
-d '{}'
# Via JSON body
curl -X POST http://localhost:8000/api/action \
-H "Content-Type: application/json" \
-d '{"signed_bundle": {...}}'
Referência da API¶
DCPVerifyMiddleware¶
Middleware do Starlette/FastAPI que intercepta as requisições e verifica os bundles do DCP.
app.add_middleware(
DCPVerifyMiddleware,
require_bundle=False, # If True, rejects requests without a bundle
header_name="x-dcp-bundle", # Header where the bundle is sent
verifier_policy={"default_mode": "hybrid_required"}, # Optional, over DEFAULT_VERIFIER_POLICY
)
Parâmetros do construtor¶
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
app |
Any |
(obrigatório) | Aplicação ASGI |
require_bundle |
bool |
False |
Recusa com 403 as requisições sem bundle |
header_name |
str |
"x-dcp-bundle" |
Nome do cabeçalho HTTP |
verifier_policy |
dict \| VerifierPolicy \| None |
None |
Política do verificador. Um dict define campos de VerifierPolicy sobre DEFAULT_VERIFIER_POLICY: um default_mode sem risk_overrides se aplica a todos os níveis de risco, um dict risk_overrides define os seus níveis por cima dos demais e risk_overrides=None mantém os níveis padrão. Um VerifierPolicy é validado de novo, e um nível de risco que o seu risk_overrides deixa de fora (todos, quando é None) recebe o seu default_mode, como o verificador V2 o resolve. Campos, níveis de risco ou modos desconhecidos lançam uma exceção na construção do middleware. |
dcp_version |
str |
"2.0" |
É guardado, mas não imposto. Se os bundles V1 são aceitos depende de verifier_policy (veja abaixo); require_dcp_v2 restringe uma rota à V2. |
trusted_principals |
dict \| callable \| None |
None |
Só por nome. Os principais em que você confia e as suas chaves: um conjunto de confiança (schemas/v2/principal_trust_set.schema.json, validado na construção do middleware), ou (tenant) -> trust set (síncrona ou assíncrona) chamada em cada requisição. Com ele, só os bundles V2 admitidos chegam à rota, e require_bundle=True é exigido (veja Principais confiáveis e strict-v1). |
profile |
dict \| None |
None |
Só por nome. O perfil strict-v1 (schemas/v2/admission_profile.schema.json), carregado na construção do middleware. Precisa de trusted_principals e de revocation_source; ele mesmo define a política do verificador. |
revocation_source |
callable \| None |
None |
Só por nome, com profile. (subjects, tenant) -> revocation snapshot \| None, síncrona (executada em uma thread de trabalho) ou assíncrona, chamada em cada admissão de um bundle verificado. deny_list_revocation_source(url) constrói uma. |
revocation_timeout |
float |
5.0 |
Só por nome. Segundos que a fonte de revogação tem para responder. Com {"revocation": True}, o prazo supervisionado: mais de 0 e até 2147483.647. |
tenant |
callable \| None |
None |
Só por nome. (request) -> str, síncrona ou assíncrona, do seu próprio código (por exemplo, o inquilino do chamador que a sua autenticação colocou em request.state). É passada às funções de confiança e de revogação. Padrão: sempre "". |
clock |
callable \| None |
None |
Só por nome, para testes: a hora em milissegundos desde a época. Padrão: o relógio do sistema. |
source_supervisor |
SourceSupervisor \| None |
None |
Só por nome, com supervise. Veja Supervisionar a fonte de confiança. |
supervise |
dict \| None |
None |
Só por nome: {"trust_timeout": seconds}, mais de 0 e até 2147483.647, para um trusted_principals assíncrono; {"revocation": True} para uma revocation_source assíncrona, com revocation_timeout; ou os dois. Veja Supervisionar a fonte de revogação. |
cancel_on_disconnect |
bool |
False |
Só por nome. Quando o cliente vai embora de verdade durante a admissão, cancela a sua invocação supervisionada da fonte; a sua resposta termina com um 499 vazio que o servidor não entrega. Precisa de max_body_bytes; veja Um cliente que vai embora. |
max_body_bytes |
int \| None |
None |
Só por nome. Um inteiro positivo (não um bool, um float, uma string, 0 nem um negativo): os bytes do corpo da requisição que o middleware aceita. Precisa do Starlette 0.28.0 ou posterior. Padrão: sem orçamento, como antes. Veja Orçamento do corpo. |
Comportamento¶
- Com
max_body_bytes, o corpo é lido primeiro, dentro do orçamento (veja Orçamento do corpo). - Extrai o bundle assinado do cabeçalho ou do corpo JSON (
signed_bundle). Um cabeçalho que não é JSON, que contém um inteiro acima do limite de 4300 dígitos do Python ou que tem um aninhamento profundo demais conta como ausência de bundle (403comrequire_bundle=True), nunca como um500. - Verifica-o com o SDK Python:
- V2 (
bundle.dcp_bundle_version: "2.0"):await verify_signed_bundle_v2(signed_bundle, registry, policy)dedcp_ai.v2, com um registro Ed25519 / ML-DSA-65 / SLH-DSA-192f e a política acima. Checa os hashes dos payloads e o manifesto, a vinculação e a expiração da sessão, a assinatura composta do bundle e de cada artefato, o modo de verificação (o mais estrito entre o modo da política para o nível de risco do passaporte e o nível de segurança da intenção), a cadeia de hash de auditoria (nodcp-ai2.10.0, só a SHA-256; 2.10.1 e posteriores também verificam os campos secundários SHA3-256 do V2-11 presentes) e a validade das chaves.composite_sig_validé o resultado do verificador para a assinatura do bundle. - V1 (
bundle.responsible_principal_record.dcp_version: "1.0"):verify_signed_bundle(), só onde o VERIFICATION.md V2-10 aceitaria um bundle só clássico, porque um bundle V1 não tem assinatura pós-quântica:allow_v1_bundlesé verdadeiro, a política aceita ed25519 (emaccepted_classical_algs, não emadvisory_rejected_algs) e não exige vinculação de audiência, expiração da sessão nem duração máxima da sessão (um bundle V1 não temintended_verifiernem janela de sessão), osresponsible_principal_record,agent_passport,intentepolicy_decisiondo bundle são objetos e as suasaudit_entriesuma lista de objetos, oagent_iddo passaporte e ohuman_iddo RPR são strings (e apublic_keye ostatusdo passaporte, quando presentes, strings, e as suascapabilitiesuma lista de strings), porque vão para o contexto do agente, e o mais estrito entre o modo da política para orisk_tierdo passaporte V1 (default_modequando não tem nenhum) e o modo do nível de segurança da intenção e da decisão de política V1 (orisk_scoreV1 vai de 0 a 1, escalado por 1000; se faltar, conta comoelevated) éclassical_onlyouhybrid_preferred. ComDEFAULT_VERIFIER_POLICY, uma intenção V1 routine ou standard passa; um pagamento, dados sensíveis, um impacto estimado alto ou uma pontuação de risco de 0,5 ou mais são recusados. - Um bundle cuja versão não pode ser detectada é recusado.
- Se for válido: guarda
DCPAgentContextemrequest.state.dcp_agent, com oswarningsdo verificador - Se falhar: responde com
403e oserrorsdo verificador (e, para a V2, oswarnings): no máximo 20 de cada, de no máximo 500 caracteres, com os caracteres de controle e de formatação do texto fornecido pelo bundle escapados. Oswarningsdo contexto são limitados da mesma forma. - Se não houver bundle e
require_bundle=False: continua sem verificação
Orçamento do corpo¶
max_body_bytes é opcional; sem ele, o middleware lê o corpo como antes, sem limite próprio. Com ele, o middleware lê o corpo inteiro da requisição antes de qualquer outra coisa (o cabeçalho do bundle, a análise do JSON, o inquilino e o conjunto de confiança, a verificação, a revogação, a rota), também quando o bundle vem no cabeçalho e com require_bundle=False:
- Um
Content-Lengthacima do orçamento recebe413{"verified": false, "errors": ["Request body exceeds max_body_bytes"]}antes de receber qualquer byte do corpo. - Os bytes realmente recebidos também são contados, então uma requisição sem
Content-Length, com um corpo em partes ou com umContent-Lengthpequeno demais recebe o mesmo413na primeira mensagem ASGI que os leva acima do orçamento (essa mensagem já foi entregue), e nada depois dela é recebido. - Um
Content-Lengthque não é uma única sequência de dígitos ASCII (um sinal, um ponto decimal, espaços, uma lista) ou que aparece duas vezes recebe400["Invalid Content-Length"], e um cliente que se desconecta enquanto envia o corpo recebe400["Request body could not be read"]. Nenhum dos dois chega à rota. - Um corpo dentro do orçamento, incluindo os vazios ou malformados, é tratado exatamente como sem a opção, e a rota o lê como sempre (
request.body(),request.json(),request.stream()): oBaseHTTPMiddlewaredo Starlette repassa um corpo lido no middleware a partir do Starlette 0.28.0. - Portanto, a opção precisa do Starlette 0.28.0 ou posterior: com um Starlette mais antigo, ou um cuja versão não pode ser lida,
max_body_byteslançaAdmissionConfigError(CONFIG_INVALID_VALUE) na construção do middleware. Atualize o FastAPI/Starlette ou deixe-a sem definir. Sem a opção nada é checado, e as versões mínimas do próprio pacote não mudam.
É um limite dos bytes do corpo recebidos, definido pela sua implantação, não parte do protocolo DCP: não limita a descompressão (conta os bytes que o servidor ASGI entrega), a profundidade do JSON, o tempo nem uma desconexão depois da leitura. Defina-o junto com os limites próprios do seu servidor.
Supervisionar a fonte de confiança¶
Sem essas opções, um trusted_principals assíncrono ou síncrono é executado como antes, sem prazo. Com elas, um assíncrono recebe um prazo e uma vaga de uma capacidade:
from dcp_ai.fastapi import DCPVerifyMiddleware, SourceSupervisor
supervisor = SourceSupervisor(max_in_flight=64, max_in_flight_per_tenant=8) # one per event loop / worker
async def trust_set_for(tenant):
return await load_trust_set(tenant)
app.add_middleware(DCPVerifyMiddleware, require_bundle=True, trusted_principals=trust_set_for,
source_supervisor=supervisor, supervise={"trust_timeout": 2.0})
- Prazo. A fonte é executada como uma Task pertencente ao supervisor. Um conjunto de confiança que chega depois de
trust_timeout, medido comloop.time()quando chega e de novo quando é usado, não é usado. A requisição recebe o mesmo503de uma fonte que falha, e o cancelamento da Task é solicitado; nada espera que ela coopere. - Capacidade. No máximo
max_in_flightchamadas a fontes são executadas ao mesmo tempo, sem fila.max_in_flight_per_tenantlimita um inquilino; sem ele, um único inquilino pode ocupar toda a capacidade. Uma chamada que não encontra vaga livre recebe o503na hora, sem chamar a fonte. Uma vaga só é liberada quando a Task da fonte termina de verdade, não quando a requisição é respondida: uma fonte que ignora o cancelamento mantém a sua vaga. Quando todas as vagas estão ocupadas dessa forma, todas as requisições recebem503até que essas fontes terminem ou o processo seja reiniciado. - Falhas. Uma fonte que cancela a si mesma, um chamador cancelado (cuja fonte mantém a sua vaga até terminar) e um conjunto de confiança inválido falham fechados. Um
trusted_principalsestático ou síncrono com essas opções lançaAdmissionConfigError: um síncrono seria executado no loop de eventos, onde nenhum prazo vale. - Escopo. A capacidade é por objeto
SourceSupervisor, compartilhada pelos middlewares aos quais você o passa. Ele se vincula ao primeiro loop de eventos que o usa, então dê a cada processo de trabalho ou loop o seu. Chamadas de outro loop ou thread falham fechadas, e umclose()de outro loop lançaRuntimeErrorsem mudar nada. await supervisor.close(drain_timeout=0)recusa as chamadas novas, responde às pendentes com503, solicita o cancelamento de todas as Tasks das fontes e espera no máximodrain_timeoutsegundos. Retorna{"in_flight": n}; essas vagas continuam ocupadas, e não há reabertura.supervisor.stats()retorna contadores, sem inquilino:open,in_flight,tenants,timeouts,saturated,closed,wrong_loop,late_results,late_errors. Eles param em 2**53 - 1.- O que ele não faz. Não interrompe o trabalho que ignora o cancelamento, não limita o trabalho que uma fonte inicia por conta própria nem isola a CPU. Um
async defque bloqueia o loop de eventos atrasa todos os prazos. Um servidor ou umasyncio.runque está encerrando ainda pode esperar por uma Task de uma fonte que ignora o cancelamento. Sem{"revocation": True}, a revogação mantémrevocation_timeoutcomo antes. A fonte de confiança continua sendo consultada antes de o bundle ser verificado, como antes.
Supervisionar a fonte de revogação¶
Com o perfil strict-v1, supervise={"revocation": True} executa uma revocation_source assíncrona como uma Task do mesmo tipo de supervisor, com revocation_timeout como prazo:
app.add_middleware(DCPVerifyMiddleware, require_bundle=True, trusted_principals=TRUST_SET, # static, or async with trust_timeout
profile={"profile": "strict-v1", "verifier_id": "verifier:acme-api"},
revocation_source=deny_list_revocation_source("https://revocations.example.com/.well-known/dcp-revocations.json"),
revocation_timeout=2.0, source_supervisor=supervisor,
supervise={"revocation": True}) # or {"trust_timeout": 2.0, "revocation": True}
- Opcional.
superviseaceitatrust_timeout,"revocation": Trueou os dois."revocation": Trueprecisa de umarevocation_sourceassíncrona (umasync def, um objeto com um__call__assíncrono, ou qualquer um dos dois dentro defunctools.partial) e funciona com um conjunto de confiança estático. Uma síncrona lançaAdmissionConfigError, porque seria executada em uma thread de trabalho que o supervisor não controla. Sem a opção nada muda: uma fonte síncrona continua sendo executada em uma thread de trabalho, comasyncio.wait_for. - As mesmas respostas. Cada resposta dá o mesmo resultado que sem supervisão. Um instantâneo é avaliado como antes.
None, uma exceção, um tempo estourado, uma fonte que cancela a si mesma, a falta de vaga livre, um supervisor fechado e um supervisor vinculado a outro loop dão403comcodes: ["REVOCATION_UNAVAILABLE"], nunca não revogado. Uma exceção cujocodeéREVOCATION_INVALIDdáREVOCATION_INVALIDsó quando chega antes do prazo. - Um caso que muda. Sem a opção,
asyncio.wait_forespera por uma fonte que suprime o seu cancelamento e usa o instantâneo que ela retorna então. Com a opção, a requisição recebeREVOCATION_UNAVAILABLEno prazo, e esse instantâneo atrasado nunca é usado. - Uma única capacidade. As chamadas de confiança e de revogação ocupam vagas do mesmo supervisor, cada uma mantida até que a sua Task termine de verdade.
- Um limite de
deny_list_revocation_source. Por padrão, ela baixa emasyncio.to_thread. Cancelar a sua Task encerra a Task e libera a sua vaga, mas não a thread de trabalho: essa thread mantém a sua conexão até que o urllib retorne. O supervisor não limita essas threads nem cancela a E/S delas. Para limitá-las, dê à fonte um orçamentoDenyListWorkers(veja Limitar os downloads da lista de negação).
Um cliente que vai embora¶
Com cancel_on_disconnect=True, um cliente que vai embora enquanto a sua requisição é admitida interrompe o trabalho dessa requisição.
- Como isso é detectado. O middleware lê primeiro o corpo inteiro: a opção precisa de
max_body_bytese, portanto, do Starlette 0.28 ou posterior. Depois, mantém umreceive()pendente enquanto a requisição é admitida: depois de lido o corpo, o servidor só pode enviar alihttp.disconnect. - Quando o cliente vai embora:
- A admissão é cancelada. A sua invocação supervisionada também:
SourceSupervisorcancela a sua Task sem esperá-la, e a vaga continua ocupada até que ela termine de verdade. Uma thread deDenyListWorkersmantém a sua vaga da mesma forma. - Nenhuma rota é executada, e nada dessa admissão é publicado em
request.state: o seudcp_agentsó é definido quando se sabe que o cliente ainda está lá, e um valor que o seu próprio código definiu não é tocado. - A resposta ASGI dessa requisição termina com um
499vazio (o «client closed request» do nginx; não registrado e não é um status do DCP), comContent-Length: 0eCache-Control: no-store. São duas mensagens ASGI que a resposta do DCP envia à camada externa, não uma resposta ao cliente: só são enviadas depois de recebidohttp.disconnect, nunca a um cliente que continua lá, e permitem que umBaseHTTPMiddlewareexterno ao DCP, que espera uma resposta, termine normalmente. Assim, o DCP funciona em qualquer ordem de middlewares. - O que chega à rede depende do servidor. O uvicorn não escreve nada, não registra nada e não envia nenhum
500para uma conexão que já não existe (medido com uvicorn 0.54 e h11). Pela especificação ASGI, outro servidor pode, em vez disso, lançar uma subclasse deOSErrorquando uma resposta é enviada por uma conexão fechada. - As camadas externas ao DCP veem esse 499: uma camada de registro anota um cliente que fechou a sua requisição, nunca um sucesso. Uma camada que transforma as respostas, ou que viola o ASGI, pode alterá-lo; isso fica fora desta garantia. Os erros reais de qualquer camada continuam sendo propagados.
- Um cliente que vai embora no meio do seu corpo recebe o mesmo final com 499, em vez do 400 enviado sem a opção.
- Nada é tirado da rota. Ela lê o mesmo corpo, e esse
receive()pendente é entregue a quem ler em seguida (a rota,request.is_disconnected(), o Starlette), nunca é lido duas vezes nem perdido. Só são interpretadas as leituras que ocorrem durante a admissão: ohttp.disconnectque um servidor envia depois de uma resposta completa não é tomado como um cliente que foi embora. - Requisitos. Checados na construção do middleware:
max_body_bytes;- toda função de fonte supervisionada (
trust_timeoutpara umtrusted_principalsinvocável,{"revocation": True}pararevocation_source); - uma função
tenantsíncrona, porque uma assíncrona poderia deixar esperando uma requisição que já foi embora.
Caso contrário, AdmissionConfigError (CONFIG_PROFILE_CONTRADICTION). Com a opção, uma função tenant que retorna um awaitable recebe o 503 de um inquilino que não pode ser resolvido, sem ser aguardada. Sem a opção nada muda.
- O que ele não faz. Não interrompe a verificação do bundle nem a função do tenant, e não interrompe uma fonte que ignora o cancelamento. Só pula as etapas que começam depois de ter visto a desconexão: atrás de camadas externas, a desconexão chega a ele algumas voltas do loop depois, então uma fonte pode já ter começado (ela é então cancelada e mantém a sua vaga até terminar). Só funciona com asyncio. Precisa de um servidor que envie http.disconnect quando a conexão é perdida: o uvicorn envia; o transporte httpx em processo só o envia quando a resposta está completa.
O que «verificado» significa¶
Um bundle verificado é internamente coerente e assinado pelas chaves que ele mesmo lista (as chaves do passaporte e as chaves de vinculação do RPR). Ele não prova quem é o principal: qualquer pessoa pode criar chaves e um RPR que nomeie qualquer human_id. Para isso, defina trusted_principals (próxima seção). Um bundle verificado também pode ser reenviado até que a sua sessão expire; verifier_policy={"require_audience_binding": True, "verifier_id": "my-service"} (o manifest.intended_verifier do bundle deve nomear este verificador), require_session_expiry e max_session_duration_seconds limitam isso, assim como o perfil strict-v1. Nada disso torna um bundle de uso único: mantenha o seu próprio registro de nonces consumidos, se precisar disso.
Principais confiáveis e strict-v1¶
Com trusted_principals, uma requisição chega à rota só quando o seu bundle é admitido (docs/ADMISSION.md, spec/profiles/admission): um bundle V2 cujas assinaturas do principal (o RPR e o manifesto do bundle, clássica e pós-quântica) foram feitas com chaves que o seu conjunto de confiança lista para esse human_id, ativas e dentro da sua janela de validade. Com profile, também se aplicam os requisitos do strict-v1: assinaturas híbridas para todos os níveis de risco, um intended_verifier assinado igual ao seu verifier_id, uma sessão assinada de no máximo 300 s e um instantâneo de revogação de no máximo 300 s que informe que o agente e todas as chaves signatárias não estão revogados.
import os
from dcp_ai.fastapi import DCPVerifyMiddleware, deny_list_revocation_source
app.add_middleware(
DCPVerifyMiddleware,
require_bundle=True,
trusted_principals=trust_set, # or a callable: lambda tenant: trust_sets[tenant]
profile={"profile": "strict-v1", "verifier_id": "api.acme.example"},
revocation_source=deny_list_revocation_source(
"https://revocation.acme.example/.well-known/dcp-revocations.json",
headers={"Authorization": f"Bearer {os.environ['REVOCATION_TOKEN']}"},
),
tenant=lambda request: request.state.tenant, # set by your own authentication
)
- As requisições recusadas recebem
403com{"verified", "admitted": false, "principal_trust", "profile", "profile_compliance", "codes", "errors"}(codesordenados,errorscomo"CODE: message", limitados e escapados) e nunca chegam à rota. Os bundles V1 são recusados: eles não trazem nenhuma assinatura do principal para ancorar. Se a função do inquilino ou a fonte do conjunto de confiança falharem, ou retornarem algo inválido, a requisição recebe503e também não chega à rota. - As requisições admitidas recebem
request.state.dcp_agentcomo acima, comadmitted=True,principal_trust,profile,profile_compliance,recorded_decision(oapprove,escalateoublockdo bundle) etrust_set_version.action_authorizedé sempreFalse: a admissão diz que a evidência é autêntica e vem de um principal confiável, não que uma ação pode ser executada. - Cada requisição é avaliada de novo: a função de confiança e a fonte de revogação são chamadas a cada admissão, então um principal removido do conjunto de confiança, uma chave marcada como
revokedali ou uma revogação listada pela fonte recusam o mesmo bundle na requisição seguinte. - A revogação falha fechada. Uma fonte que lança uma exceção, estoura o tempo (
revocation_timeout) ou retornaNonedáREVOCATION_UNAVAILABLE; uma exceção cujocodeéREVOCATION_INVALID, ou um instantâneo fora deschemas/v2/revocation_snapshot.schema.json, dáREVOCATION_INVALID. Nenhum deles é tomado como não revogado. deny_list_revocation_source(url, *, headers=None, timeout=5.0, workers=None)lê uma lista de negação completa no formato do/.well-known/dcp-revocations.jsondo serviço de revogação de referência (updated_at,revocations[].agent_id,kid_revocations[].revoked_kid) com a biblioteca padrão: ochecked_atdo instantâneo éupdated_at, um agente ou uma chave listados estão revogados e todos os demais sujeitos não estão. Um erro de rede, um tempo estourado ou uma resposta que não é 2xx éREVOCATION_UNAVAILABLE, e um corpo com outro formato,REVOCATION_INVALID. Não segue redirecionamentos (os seus cabeçalhos só vão para a sua URL), e o HTTPS precisa de TLS 1.2 ou posterior. Usehttpse guarde as credenciais em variáveis de ambiente ou em um vault.timeoutlimita cada operação de socket, a leitura do corpo e quanto uma chamada espera; não limita a resolução de DNS, nem uma linha de status e cabeçalhos que chegam aos poucos.workersé descrito abaixo.- A configuração só vem do seu código. O conjunto de confiança, o perfil, a política, a fonte de revogação e o inquilino nunca são lidos de um cabeçalho, de uma consulta ou de um corpo da requisição: uma requisição que nomeia outro inquilino não muda nada.
- Os erros de configuração lançam
AdmissionConfigError(com umcode) na construção do middleware:trusted_principalsouprofilesemrequire_bundle=True, um perfil semtrusted_principalsou semrevocation_source, ou comverifier_policy(CONFIG_PROFILE_CONTRADICTION); um conjunto de confiança ou um perfil inválidos (o seu próprio código); um argumento de tipo errado, ourevocation_sourcesemprofile(CONFIG_INVALID_VALUE). O Starlette constrói a pilha de middlewares na primeira requisição, não emadd_middleware, então um erro desses aparece primeiro como um500em todas as requisições (a rota nunca é alcançada). Para falhar na inicialização, carregue você mesmo o conjunto de confiança e o perfil (create_principal_trust_store,resolve_strict_v1_profile) e passe os objetos carregados. - Uma fonte de revogação síncrona é executada em uma thread de trabalho; quando ela estoura o tempo, a requisição é recusada na hora, mas a thread continua até que a fonte retorne. Prefira uma fonte assíncrona, ou uma com o seu próprio tempo limite, como
deny_list_revocation_source(url, timeout=...): ela não usa nenhum proxy do ambiente, não segue redirecionamentos e recusa uma lista de mais de 16 MiB. O seutimeoutlimita cada operação de socket e a leitura do corpo, não a resolução de DNS nem uma linha de status que chega aos poucos.
Executável, offline, com os vetores de admissão compartilhados: python integrations/fastapi/examples/strict_v1.py.
Limitar os downloads da lista de negação¶
Por padrão, cada chamada de deny_list_revocation_source baixa em asyncio.to_thread, e nada conta essas threads depois que a sua chamada deixou de esperar. Com um orçamento DenyListWorkers, ele as conta:
from dcp_ai.v2 import DenyListWorkers, deny_list_revocation_source
workers = DenyListWorkers(max_workers=8, max_workers_per_tenant=2) # one per process
source = deny_list_revocation_source(
"https://revocations.example.com/.well-known/dcp-revocations.json",
timeout=5.0,
workers=workers,
)
- Capacidade. No máximo
max_workersdownloads são executados ao mesmo tempo (um inteiro de 1 a 64), e no máximomax_workers_per_tenantpara um inquilino; sem ele, um único inquilino pode ocupar todos. - Cada download é executado em uma thread própria, iniciada só depois de a sua vaga ser reservada.
- Uma chamada que não encontra vaga livre recebe
REVOCATION_UNAVAILABLEna hora, sem se conectar. Não há fila. - A capacidade é por objeto: dê o mesmo a todas as fontes que devem compartilhá-la.
- A vaga é mantida até que o download termine de verdade, não quando a sua chamada deixa de esperar: um tempo estourado, uma requisição cancelada, o prazo do supervisor do C3 e um loop de eventos fechado não a liberam.
- O orçamento conta os downloads em andamento. A thread que acabou de terminar um ainda existe por um momento enquanto entrega o seu resultado e sai.
- As mesmas respostas.
- O download é o mesmo GET, com as mesmas regras de TLS, proxy, redirecionamento e tamanho.
- O corpo continua sendo checado e analisado no loop de eventos.
- Os erros são os mesmos de sem a opção.
- Um resultado que chega, ou é usado, depois de
timeoutéREVOCATION_UNAVAILABLE, mesmo quando o loop estava ocupado demais para executar o temporizador. workers.close()recusa os downloads novos e retorna{"in_flight": n}. Não espera nem interrompe os que estão em andamento, cujas vagas continuam ocupadas até que terminem; não há reabertura.workers.stats()retorna contadores sem URL, inquilino nem cabeçalho:open,in_flight,tenants,saturated,closed,start_failed,late_results,dropped.- A saída do processo espera. As threads não são threads daemon, então o interpretador espera, quando o processo sai, por um download que ainda esteja em andamento. É o que já acontece com
asyncio.to_thread, e pode demorar: nada aqui interrompe DNS, TLS ou urllib, e cancelar uma chamada só deixa de esperá-la. Para encerrar um processo que espera, conte com o encerramento forçado do seu gerenciador de processos após um período de tolerância. - Um por processo. Em um processo bifurcado depois de criá-lo,
stats()eclose()lançamRuntimeErrore a fonte dáREVOCATION_UNAVAILABLE, sem tocar no bloqueio nem nos contadores do pai: crie um em cada processo de trabalho. - O que ele não faz. Limita quantos downloads são executados, não quanto tempo um leva. O prazo do corpo é checado entre leituras bloqueantes; uma leitura já em andamento pode ultrapassá-lo.
require_dcp(request)¶
Dependência do FastAPI que exige um agente do DCP verificado.
from fastapi import Depends
from dcp_ai.fastapi import require_dcp, DCPAgentContext
@app.post("/protected")
async def protected_route(agent: DCPAgentContext = Depends(require_dcp)):
return {"agent_id": agent.agent_id}
- Retorna:
DCPAgentContext - Lança:
HTTPException(403)se não houver nenhum agente verificado
DCPAgentContext¶
Dataclass com os dados do agente verificado:
@dataclass
class DCPAgentContext:
dcp_version: str = "1.0"
agent_id: str = ""
human_id: str = ""
session_nonce: str = "" # V2: verified session nonce
public_key: str = ""
capabilities: list[str] = field(default_factory=list)
risk_tier: str = "medium"
status: str = "active"
passport: dict[str, Any] = field(default_factory=dict)
rpr: dict[str, Any] = field(default_factory=dict)
intent: dict[str, Any] = field(default_factory=dict)
policy_decision: dict[str, Any] = field(default_factory=dict)
composite_sig_valid: bool = False # V2: the bundle signature verified
session_binding_valid: bool = False # V2: session binding verified
blinded_rpr: bool = False
lifecycle_state: str = "active" # DCP-05
mandate_id: str = "" # DCP-09
mandate_valid: bool = False # DCP-09
warnings: list[str] = field(default_factory=list) # V2: verifier warnings
# Admission: set only with trusted_principals (admitted stays False otherwise)
admitted: bool = False
principal_trust: str = "not_evaluated" # "trusted" when admitted with trusted_principals
profile: str | None = None # "strict-v1" under the profile
profile_compliance: str = "not_evaluated"
recorded_decision: str | None = None # approve, escalate or block
trust_set_version: str | None = None
action_authorized: bool = False # always False
require_dcp_commissioned(request)¶
Dependência do FastAPI que recusa um agente cujo lifecycle_state é decommissioned. O middleware preenche lifecycle_state a partir do status do passaporte; ele não lê um certificado de comissionamento nem um registro de ciclo de vida do DCP-05.
@app.post("/active-only")
async def active_only(agent: DCPAgentContext = Depends(require_dcp_commissioned)):
return {"agent_id": agent.agent_id}
require_dcp_mandated(request)¶
Dependência do FastAPI que exige um mandato de delegação (DCP-09 §3.1), mas só checa se mandate_id não está vazio: não obtém o mandato, não verifica a sua assinatura nem checa mandate_valid. O middleware não preenche mandate_id por conta própria, então todas as requisições recebem 403, a menos que o seu próprio código o defina (e então cabe a você checar o mandato).
@app.post("/delegated")
async def delegated(agent: DCPAgentContext = Depends(require_dcp_mandated)):
return {"agent_id": agent.agent_id, "mandate_id": agent.mandate_id}
Exemplo avançado — middleware global + rotas seletivas¶
from fastapi import FastAPI, Depends, HTTPException, Request
from dcp_ai.fastapi import DCPVerifyMiddleware, require_dcp, DCPAgentContext
app = FastAPI()
# Global middleware (non-blocking, only verifies if a bundle is present)
app.add_middleware(DCPVerifyMiddleware, require_bundle=False)
# Public route
@app.get("/health")
async def health():
return {"ok": True}
# Route that REQUIRES a verified agent
@app.post("/agent/execute")
async def execute(agent: DCPAgentContext = Depends(require_dcp)):
if "api_call" not in agent.capabilities:
raise HTTPException(403, "Capability 'api_call' required")
return {"executed_by": agent.agent_id, "risk": agent.risk_tier}
# Route that uses the agent if available
@app.get("/info")
async def info(request: Request):
agent = getattr(request.state, "dcp_agent", None)
if agent:
return {"mode": "verified", "agent": agent.agent_id}
return {"mode": "anonymous"}
Desenvolvimento¶
A partir de um checkout:
Dependências¶
dcp-ai— SDK de verificação do DCPfastapi— framework webstarlette— conjunto de ferramentas ASGI (incluído com o FastAPI)
Licença¶
Apache-2.0