fazflow-policy-gate 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fazflow_policy_gate-0.1.0/PKG-INFO +94 -0
- fazflow_policy_gate-0.1.0/README.md +70 -0
- fazflow_policy_gate-0.1.0/fazflow_policy_gate/__init__.py +288 -0
- fazflow_policy_gate-0.1.0/fazflow_policy_gate/policycore_wasm.wasm +0 -0
- fazflow_policy_gate-0.1.0/fazflow_policy_gate.egg-info/PKG-INFO +94 -0
- fazflow_policy_gate-0.1.0/fazflow_policy_gate.egg-info/SOURCES.txt +10 -0
- fazflow_policy_gate-0.1.0/fazflow_policy_gate.egg-info/dependency_links.txt +1 -0
- fazflow_policy_gate-0.1.0/fazflow_policy_gate.egg-info/requires.txt +1 -0
- fazflow_policy_gate-0.1.0/fazflow_policy_gate.egg-info/top_level.txt +1 -0
- fazflow_policy_gate-0.1.0/pyproject.toml +47 -0
- fazflow_policy_gate-0.1.0/setup.cfg +4 -0
- fazflow_policy_gate-0.1.0/tests/test_gate.py +315 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fazflow-policy-gate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Governança de agentes de IA: decide cada ação antes que ela aconteça. Binding sobre o artefato WASM, sem lógica de decisão.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://fazflow.com
|
|
7
|
+
Project-URL: Repository, https://github.com/JulioCesar1582/FazFlow-IA
|
|
8
|
+
Project-URL: Issues, https://github.com/JulioCesar1582/FazFlow-IA/issues
|
|
9
|
+
Keywords: ai-agents,authorization,policy,cedar,governance,audit
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Natural Language :: Portuguese (Brazilian)
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: wasmtime>=25
|
|
24
|
+
|
|
25
|
+
# fazflow-policy-gate
|
|
26
|
+
|
|
27
|
+
Decide cada ação de um agente de IA **antes** que ela aconteça, e registra a
|
|
28
|
+
decisão numa cadeia verificável.
|
|
29
|
+
|
|
30
|
+
A decisão é local: o motor roda dentro do seu processo, e os argumentos da
|
|
31
|
+
requisição nunca saem da sua infraestrutura.
|
|
32
|
+
|
|
33
|
+
## Instalar
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install fazflow-policy-gate
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
O artefato WebAssembly viaja dentro do pacote. Não há passo de download.
|
|
40
|
+
|
|
41
|
+
## Usar
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from fazflow_policy_gate import PolicyGate
|
|
45
|
+
|
|
46
|
+
gate = PolicyGate.load()
|
|
47
|
+
gate.load_bundle({"versao": "v1", "politicas": fonte_cedar})
|
|
48
|
+
|
|
49
|
+
d = gate.evaluate({
|
|
50
|
+
"user": 'User::"u_8891"',
|
|
51
|
+
"agent": 'Agent::"copiloto"',
|
|
52
|
+
"action": "Tool::query",
|
|
53
|
+
"resource": 'Dataset::"notas"',
|
|
54
|
+
"context": contexto, # os 14 campos
|
|
55
|
+
"entities": entidades, # User, Agent, Workload e o recurso
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if d["verdict"] == "Deny":
|
|
59
|
+
raise RuntimeError(d["reason_code"])
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Duas camadas
|
|
63
|
+
|
|
64
|
+
A política é avaliada em duas camadas — a do usuário e a do agente — e **as
|
|
65
|
+
duas precisam permitir**. Uma política só com `principal is Agent` responde
|
|
66
|
+
`Deny` com `DeniedNoUserPermit`, e é o erro mais comum do primeiro dia.
|
|
67
|
+
|
|
68
|
+
## Obrigações
|
|
69
|
+
|
|
70
|
+
Quando o veredito é `Transform`, `d["obligations"]` traz o que precisa ser
|
|
71
|
+
feito — `mascarar:cpf,email`, `truncar:100`. **Aplicá-las é responsabilidade de
|
|
72
|
+
quem chamou.** O SDK não toca no dado: ele não viu o conteúdo e não está no
|
|
73
|
+
caminho da resposta.
|
|
74
|
+
|
|
75
|
+
Ignorar uma obrigação é o modo de falha mais silencioso do sistema: o log
|
|
76
|
+
registra que a máscara foi exigida, a auditoria vê que foi exigida, e o dado
|
|
77
|
+
saiu inteiro.
|
|
78
|
+
|
|
79
|
+
## Concorrência
|
|
80
|
+
|
|
81
|
+
Cada instância WASM é single-threaded. Sob concorrência use `PolicyGatePool`,
|
|
82
|
+
que mantém uma instância por thread lógica. Não chame métodos da instância crua
|
|
83
|
+
enquanto outra thread avalia — os buffers são compartilhados.
|
|
84
|
+
|
|
85
|
+
## O hash
|
|
86
|
+
|
|
87
|
+
`gate.version()["artefato_hash"]` é o `sha256` do artefato que foi de fato
|
|
88
|
+
instanciado, e é o mesmo valor que entra em cada decisão como `engine_hash`.
|
|
89
|
+
Compará-lo com o que o painel mostra prova que o binário que decidiu é o que
|
|
90
|
+
você conferiu.
|
|
91
|
+
|
|
92
|
+
## Licença
|
|
93
|
+
|
|
94
|
+
Apache-2.0
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# fazflow-policy-gate
|
|
2
|
+
|
|
3
|
+
Decide cada ação de um agente de IA **antes** que ela aconteça, e registra a
|
|
4
|
+
decisão numa cadeia verificável.
|
|
5
|
+
|
|
6
|
+
A decisão é local: o motor roda dentro do seu processo, e os argumentos da
|
|
7
|
+
requisição nunca saem da sua infraestrutura.
|
|
8
|
+
|
|
9
|
+
## Instalar
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install fazflow-policy-gate
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
O artefato WebAssembly viaja dentro do pacote. Não há passo de download.
|
|
16
|
+
|
|
17
|
+
## Usar
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from fazflow_policy_gate import PolicyGate
|
|
21
|
+
|
|
22
|
+
gate = PolicyGate.load()
|
|
23
|
+
gate.load_bundle({"versao": "v1", "politicas": fonte_cedar})
|
|
24
|
+
|
|
25
|
+
d = gate.evaluate({
|
|
26
|
+
"user": 'User::"u_8891"',
|
|
27
|
+
"agent": 'Agent::"copiloto"',
|
|
28
|
+
"action": "Tool::query",
|
|
29
|
+
"resource": 'Dataset::"notas"',
|
|
30
|
+
"context": contexto, # os 14 campos
|
|
31
|
+
"entities": entidades, # User, Agent, Workload e o recurso
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if d["verdict"] == "Deny":
|
|
35
|
+
raise RuntimeError(d["reason_code"])
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Duas camadas
|
|
39
|
+
|
|
40
|
+
A política é avaliada em duas camadas — a do usuário e a do agente — e **as
|
|
41
|
+
duas precisam permitir**. Uma política só com `principal is Agent` responde
|
|
42
|
+
`Deny` com `DeniedNoUserPermit`, e é o erro mais comum do primeiro dia.
|
|
43
|
+
|
|
44
|
+
## Obrigações
|
|
45
|
+
|
|
46
|
+
Quando o veredito é `Transform`, `d["obligations"]` traz o que precisa ser
|
|
47
|
+
feito — `mascarar:cpf,email`, `truncar:100`. **Aplicá-las é responsabilidade de
|
|
48
|
+
quem chamou.** O SDK não toca no dado: ele não viu o conteúdo e não está no
|
|
49
|
+
caminho da resposta.
|
|
50
|
+
|
|
51
|
+
Ignorar uma obrigação é o modo de falha mais silencioso do sistema: o log
|
|
52
|
+
registra que a máscara foi exigida, a auditoria vê que foi exigida, e o dado
|
|
53
|
+
saiu inteiro.
|
|
54
|
+
|
|
55
|
+
## Concorrência
|
|
56
|
+
|
|
57
|
+
Cada instância WASM é single-threaded. Sob concorrência use `PolicyGatePool`,
|
|
58
|
+
que mantém uma instância por thread lógica. Não chame métodos da instância crua
|
|
59
|
+
enquanto outra thread avalia — os buffers são compartilhados.
|
|
60
|
+
|
|
61
|
+
## O hash
|
|
62
|
+
|
|
63
|
+
`gate.version()["artefato_hash"]` é o `sha256` do artefato que foi de fato
|
|
64
|
+
instanciado, e é o mesmo valor que entra em cada decisão como `engine_hash`.
|
|
65
|
+
Compará-lo com o que o painel mostra prova que o binário que decidiu é o que
|
|
66
|
+
você conferiu.
|
|
67
|
+
|
|
68
|
+
## Licença
|
|
69
|
+
|
|
70
|
+
Apache-2.0
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""M4 — binding Python sobre o artefato WASM.
|
|
2
|
+
|
|
3
|
+
> **R29 — Bindings sao finos.** Os SDKs TS e Python NAO DEVEM conter logica de
|
|
4
|
+
> decisao, valor padrao, tratamento de campo ausente ou normalizacao. Apenas
|
|
5
|
+
> serializar, chamar, desserializar.
|
|
6
|
+
|
|
7
|
+
Superficie identica a do SDK TypeScript, com nomes idiomaticos de cada
|
|
8
|
+
linguagem. Nenhuma funcionalidade existe em uma e nao na outra — e o que
|
|
9
|
+
permite que M6 rode o mesmo corpus nos dois e exija saida byte a byte igual.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from wasmtime import Engine, Instance, Linker, Module, Store
|
|
20
|
+
|
|
21
|
+
__all__ = ["PolicyGate", "PolicyGateError"]
|
|
22
|
+
|
|
23
|
+
#: O artefato que viaja dentro do pacote.
|
|
24
|
+
#:
|
|
25
|
+
#: `PolicyGate.load()` sem argumento usa este caminho, e e isso que faz
|
|
26
|
+
#: `pip install fazflow-policy-gate` funcionar sem mais nenhum passo. Sem
|
|
27
|
+
#: ele, a primeira coisa que o pacote pediria e que a pessoa fosse buscar
|
|
28
|
+
#: um `.wasm` em outro lugar — que e exatamente o beco que este SDK existe
|
|
29
|
+
#: para evitar.
|
|
30
|
+
#:
|
|
31
|
+
#: O arquivo NAO esta no git: ele e colocado aqui no momento da publicacao.
|
|
32
|
+
#: Quem roda a partir do codigo-fonte passa o caminho a mao.
|
|
33
|
+
ARTEFATO_EMBUTIDO = Path(__file__).parent / "policycore_wasm.wasm"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PolicyGateError(Exception):
|
|
37
|
+
"""Erro de politica ou de fronteira.
|
|
38
|
+
|
|
39
|
+
O `codigo` vem do WASM sem traducao — o SDK nao renomeia nada.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, codigo: str, detalhe: str) -> None:
|
|
43
|
+
super().__init__(f"{codigo}: {detalhe}")
|
|
44
|
+
self.codigo = codigo
|
|
45
|
+
self.detalhe = detalhe
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PolicyGate:
|
|
49
|
+
"""Motor de decisao carregado a partir do artefato WASM."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, store: Store, instancia: Instance, artefato_hash: str) -> None:
|
|
52
|
+
self._store = store
|
|
53
|
+
self._i = instancia
|
|
54
|
+
self._artefato_hash = artefato_hash
|
|
55
|
+
self._handle = 0
|
|
56
|
+
e = instancia.exports(store)
|
|
57
|
+
self._memoria = e["memory"]
|
|
58
|
+
self._entrada_reservar = e["fz_entrada_reservar"]
|
|
59
|
+
self._saida_ptr = e["fz_saida_ptr"]
|
|
60
|
+
self._saida_len = e["fz_saida_len"]
|
|
61
|
+
self._carregar = e["fz_carregar_bundle"]
|
|
62
|
+
self._descarregar = e["fz_descarregar_bundle"]
|
|
63
|
+
self._avaliar = e["fz_avaliar"]
|
|
64
|
+
self._versao = e["fz_versao"]
|
|
65
|
+
|
|
66
|
+
# -- carga -------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def load(cls, caminho_wasm: str | Path | None = None) -> "PolicyGate":
|
|
70
|
+
"""Carrega o artefato WASM.
|
|
71
|
+
|
|
72
|
+
Sem argumento, usa o `.wasm` que viaja dentro do pacote.
|
|
73
|
+
|
|
74
|
+
R27 — instanciado com um `Linker` **vazio**. Nenhuma capacidade de
|
|
75
|
+
host: sem WASI, sem relogio, sem entropia, sem filesystem, sem rede.
|
|
76
|
+
Se o modulo um dia passar a exigir um import, esta chamada falha — que
|
|
77
|
+
e o comportamento desejado, e nao algo a consertar concedendo a
|
|
78
|
+
capacidade.
|
|
79
|
+
"""
|
|
80
|
+
caminho = Path(caminho_wasm) if caminho_wasm is not None else ARTEFATO_EMBUTIDO
|
|
81
|
+
try:
|
|
82
|
+
bytes_wasm = caminho.read_bytes()
|
|
83
|
+
except OSError as e:
|
|
84
|
+
# A mensagem precisa dizer QUAL caminho falhou e o que fazer.
|
|
85
|
+
#
|
|
86
|
+
# Sem isto, quem instalou de um pacote sem o artefato recebe um
|
|
87
|
+
# `FileNotFoundError` cru apontando para dentro de `site-packages`,
|
|
88
|
+
# e a conclusao natural e que o pacote esta quebrado — quando o que
|
|
89
|
+
# falta e um passo de publicacao.
|
|
90
|
+
raise PolicyGateError(
|
|
91
|
+
"ArtefatoAusente",
|
|
92
|
+
f"nao consegui ler o artefato WASM em `{caminho}`: {e}. "
|
|
93
|
+
"Se voce instalou este pacote de um indice, isto e defeito da "
|
|
94
|
+
"publicacao; se esta rodando do codigo-fonte, passe o caminho: "
|
|
95
|
+
'PolicyGate.load("./policycore_wasm.wasm").',
|
|
96
|
+
) from e
|
|
97
|
+
|
|
98
|
+
# R28 — o hash do artefato acompanha toda decisao, calculado sobre os
|
|
99
|
+
# bytes que foram de fato instanciados.
|
|
100
|
+
artefato_hash = "sha256:" + hashlib.sha256(bytes_wasm).hexdigest()
|
|
101
|
+
|
|
102
|
+
engine = Engine()
|
|
103
|
+
store = Store(engine)
|
|
104
|
+
modulo = Module(engine, bytes_wasm)
|
|
105
|
+
linker = Linker(engine)
|
|
106
|
+
instancia = linker.instantiate(store, modulo)
|
|
107
|
+
return cls(store, instancia, artefato_hash)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def artefato_hash(self) -> str:
|
|
111
|
+
return self._artefato_hash
|
|
112
|
+
|
|
113
|
+
# -- memoria -----------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _escrever_entrada(self, texto: str) -> int:
|
|
116
|
+
dados = texto.encode("utf-8")
|
|
117
|
+
ptr = self._entrada_reservar(self._store, len(dados))
|
|
118
|
+
self._memoria.write(self._store, dados, ptr)
|
|
119
|
+
return len(dados)
|
|
120
|
+
|
|
121
|
+
def _ler_saida(self, n: int) -> str:
|
|
122
|
+
ptr = self._saida_ptr(self._store)
|
|
123
|
+
return self._memoria.read(self._store, ptr, ptr + n).decode("utf-8")
|
|
124
|
+
|
|
125
|
+
# -- API ---------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def memoria_em_bytes(self) -> int:
|
|
128
|
+
"""Quantos bytes a memoria linear do WASM ocupa agora.
|
|
129
|
+
|
|
130
|
+
Paridade com `memoriaEmBytes()` do SDK TypeScript. Existe para
|
|
131
|
+
responder "este gate esta crescendo?" sem entregar a instancia inteira
|
|
132
|
+
— com ela em maos daria para chamar `fz_descarregar_bundle` e deixar o
|
|
133
|
+
gate sem politica, que e poder demais para uma pergunta de
|
|
134
|
+
observabilidade.
|
|
135
|
+
|
|
136
|
+
A memoria linear do WASM so CRESCE: ela nao devolve pagina ao sistema.
|
|
137
|
+
Um numero que sobe a cada rollout de politica e vazamento, e nao carga.
|
|
138
|
+
"""
|
|
139
|
+
return self._memoria.data_len(self._store)
|
|
140
|
+
|
|
141
|
+
def load_bundle(self, bundle: dict[str, str]) -> int:
|
|
142
|
+
n = self._escrever_entrada(json.dumps(bundle))
|
|
143
|
+
handle = self._carregar(self._store, n)
|
|
144
|
+
if handle == 0:
|
|
145
|
+
erro = json.loads(self._ler_saida(self._saida_len(self._store)))
|
|
146
|
+
raise PolicyGateError(erro["erro"], erro["detalhe"])
|
|
147
|
+
|
|
148
|
+
# O bundle anterior sai da memoria do WASM.
|
|
149
|
+
#
|
|
150
|
+
# `fz_descarregar_bundle` ja estava ligado na linha 50 e so era usado
|
|
151
|
+
# pelo `close()`. Aqui, `self._handle = handle` sobrescrevia a
|
|
152
|
+
# referencia e o bundle antigo continuava dentro do modulo, alcancavel
|
|
153
|
+
# por handle e impossivel de liberar depois — o unico handle que
|
|
154
|
+
# existia estava nesta variavel.
|
|
155
|
+
#
|
|
156
|
+
# Um processo de vida longa que acompanha rollout carrega um bundle
|
|
157
|
+
# novo a cada publicacao. A memoria linear do WASM so CRESCE, entao o
|
|
158
|
+
# vazamento e permanente, e o sintoma aparece semanas depois como "o
|
|
159
|
+
# gate foi ficando pesado" — sem relacao obvia com publicar politica.
|
|
160
|
+
#
|
|
161
|
+
# A descarga vem DEPOIS de o carregamento dar certo: descarregar antes
|
|
162
|
+
# deixaria o gate sem bundle nenhum se o novo fosse invalido, trocando
|
|
163
|
+
# um vazamento por uma indisponibilidade.
|
|
164
|
+
if self._handle != 0 and self._handle != handle:
|
|
165
|
+
self._descarregar(self._store, self._handle)
|
|
166
|
+
|
|
167
|
+
self._handle = handle
|
|
168
|
+
return handle
|
|
169
|
+
|
|
170
|
+
def evaluate(self, requisicao: dict[str, Any]) -> dict[str, Any]:
|
|
171
|
+
"""Avalia uma requisicao.
|
|
172
|
+
|
|
173
|
+
A requisicao vai como o chamador a escreveu. O SDK nao preenche campo
|
|
174
|
+
ausente nem normaliza nada: se falta um campo do contexto, o WASM
|
|
175
|
+
devolve `ContextoInvalido`, que e o que M2/R12 quer que aconteca.
|
|
176
|
+
"""
|
|
177
|
+
saida = json.loads(self.evaluate_raw(json.dumps(requisicao)))
|
|
178
|
+
if "erro" in saida:
|
|
179
|
+
raise PolicyGateError(saida["erro"], saida["detalhe"])
|
|
180
|
+
return saida
|
|
181
|
+
|
|
182
|
+
def evaluate_raw(self, requisicao_json: str) -> str:
|
|
183
|
+
"""A mesma avaliacao, devolvendo o JSON **cru**.
|
|
184
|
+
|
|
185
|
+
Existe para M6/R40: a suite compara byte a byte, e desserializar e
|
|
186
|
+
reserializar reordenaria chaves, destruindo exatamente o que ela mede.
|
|
187
|
+
"""
|
|
188
|
+
if self._handle == 0:
|
|
189
|
+
raise PolicyGateError("SemBundle", "chame load_bundle antes de evaluate")
|
|
190
|
+
n = self._escrever_entrada(requisicao_json)
|
|
191
|
+
return self._ler_saida(self._avaliar(self._store, self._handle, n))
|
|
192
|
+
|
|
193
|
+
def version(self) -> dict[str, Any]:
|
|
194
|
+
n = self._escrever_entrada(self._artefato_hash)
|
|
195
|
+
return json.loads(self._ler_saida(self._versao(self._store, n)))
|
|
196
|
+
|
|
197
|
+
def unload_bundle(self) -> bool:
|
|
198
|
+
if self._handle == 0:
|
|
199
|
+
return False
|
|
200
|
+
existia = self._descarregar(self._store, self._handle) == 1
|
|
201
|
+
self._handle = 0
|
|
202
|
+
return existia
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class PolicyGatePool:
|
|
206
|
+
"""R26 — pool de instancias.
|
|
207
|
+
|
|
208
|
+
> **R26 — Pool de instancias.** WASM nao tem threads. Concorrencia DEVE
|
|
209
|
+
> ser atendida por pool de instancias, uma por thread logica, cada uma com
|
|
210
|
+
> o mesmo bundle. O tamanho do pool e configuravel; o padrao DEVE ser o
|
|
211
|
+
> numero de CPUs.
|
|
212
|
+
|
|
213
|
+
Cada instancia WASM tem sua propria memoria linear e e single-threaded.
|
|
214
|
+
Compartilhar uma entre threads corromperia os buffers de entrada e saida —
|
|
215
|
+
duas requisicoes escreveriam na mesma area. O pool resolve isso do jeito
|
|
216
|
+
mais simples que funciona: N instancias independentes, uma por thread em
|
|
217
|
+
voo, cada uma com o mesmo bundle carregado.
|
|
218
|
+
|
|
219
|
+
Nenhuma instancia guarda estado entre chamadas (R25), entao qual delas
|
|
220
|
+
atende qual requisicao e irrelevante para o resultado — e A54 prova isso
|
|
221
|
+
comparando o resultado concorrente com o serial.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(self, caminho_wasm: str | Path, bundle: dict[str, str], tamanho: int | None = None) -> None:
|
|
225
|
+
import os
|
|
226
|
+
import queue
|
|
227
|
+
|
|
228
|
+
self._tamanho = tamanho if tamanho is not None else (os.cpu_count() or 1)
|
|
229
|
+
self._livres: "queue.Queue[PolicyGate]" = queue.Queue()
|
|
230
|
+
self._todas: list[PolicyGate] = []
|
|
231
|
+
|
|
232
|
+
for _ in range(self._tamanho):
|
|
233
|
+
gate = PolicyGate.load(caminho_wasm)
|
|
234
|
+
gate.load_bundle(bundle)
|
|
235
|
+
self._todas.append(gate)
|
|
236
|
+
self._livres.put(gate)
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def tamanho(self) -> int:
|
|
240
|
+
return self._tamanho
|
|
241
|
+
|
|
242
|
+
def evaluate(self, requisicao: dict[str, Any]) -> dict[str, Any]:
|
|
243
|
+
gate = self._livres.get()
|
|
244
|
+
try:
|
|
245
|
+
return gate.evaluate(requisicao)
|
|
246
|
+
finally:
|
|
247
|
+
self._livres.put(gate)
|
|
248
|
+
|
|
249
|
+
def evaluate_raw(self, requisicao_json: str) -> str:
|
|
250
|
+
gate = self._livres.get()
|
|
251
|
+
try:
|
|
252
|
+
return gate.evaluate_raw(requisicao_json)
|
|
253
|
+
finally:
|
|
254
|
+
self._livres.put(gate)
|
|
255
|
+
|
|
256
|
+
def version(self) -> dict[str, Any]:
|
|
257
|
+
"""A versao do motor, pedida a uma instancia LIVRE.
|
|
258
|
+
|
|
259
|
+
Isto era `return self._todas[0].version()`, e o comentario do proprio
|
|
260
|
+
pool explica por que aquilo estava errado:
|
|
261
|
+
|
|
262
|
+
> Cada instancia WASM tem sua propria memoria linear e e
|
|
263
|
+
> single-threaded. Compartilhar uma entre threads corromperia os
|
|
264
|
+
> buffers de entrada e saida — duas requisicoes escreveriam na mesma
|
|
265
|
+
> area.
|
|
266
|
+
|
|
267
|
+
`self._todas[0]` alcanca a instancia 0 sem passar pela fila de livres.
|
|
268
|
+
Enquanto uma thread avalia uma decisao nela, outra chamando `version()`
|
|
269
|
+
escreve na MESMA area de saida — e a decisao que volta e a que ficou
|
|
270
|
+
por ultimo. Nao ha erro, nao ha excecao: sai um veredito trocado, ou um
|
|
271
|
+
JSON meio de cada, de forma intermitente e sob carga.
|
|
272
|
+
|
|
273
|
+
Um painel que mostra a versao do motor ao lado das decisoes basta para
|
|
274
|
+
disparar isso, e o sintoma nao aponta para lugar nenhum.
|
|
275
|
+
|
|
276
|
+
O conserto e usar a mesma disciplina de `evaluate_raw`: pegar da fila,
|
|
277
|
+
devolver no `finally`. `version()` e barata, e a alternativa —
|
|
278
|
+
memoizar a versao no construtor — economizaria uma passagem pela fila
|
|
279
|
+
ao custo de o pool poder mentir sobre o que esta carregado.
|
|
280
|
+
"""
|
|
281
|
+
gate = self._livres.get()
|
|
282
|
+
try:
|
|
283
|
+
return gate.version()
|
|
284
|
+
finally:
|
|
285
|
+
self._livres.put(gate)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
__all__.append("PolicyGatePool")
|
|
Binary file
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fazflow-policy-gate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Governança de agentes de IA: decide cada ação antes que ela aconteça. Binding sobre o artefato WASM, sem lógica de decisão.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://fazflow.com
|
|
7
|
+
Project-URL: Repository, https://github.com/JulioCesar1582/FazFlow-IA
|
|
8
|
+
Project-URL: Issues, https://github.com/JulioCesar1582/FazFlow-IA/issues
|
|
9
|
+
Keywords: ai-agents,authorization,policy,cedar,governance,audit
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Natural Language :: Portuguese (Brazilian)
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: wasmtime>=25
|
|
24
|
+
|
|
25
|
+
# fazflow-policy-gate
|
|
26
|
+
|
|
27
|
+
Decide cada ação de um agente de IA **antes** que ela aconteça, e registra a
|
|
28
|
+
decisão numa cadeia verificável.
|
|
29
|
+
|
|
30
|
+
A decisão é local: o motor roda dentro do seu processo, e os argumentos da
|
|
31
|
+
requisição nunca saem da sua infraestrutura.
|
|
32
|
+
|
|
33
|
+
## Instalar
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install fazflow-policy-gate
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
O artefato WebAssembly viaja dentro do pacote. Não há passo de download.
|
|
40
|
+
|
|
41
|
+
## Usar
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from fazflow_policy_gate import PolicyGate
|
|
45
|
+
|
|
46
|
+
gate = PolicyGate.load()
|
|
47
|
+
gate.load_bundle({"versao": "v1", "politicas": fonte_cedar})
|
|
48
|
+
|
|
49
|
+
d = gate.evaluate({
|
|
50
|
+
"user": 'User::"u_8891"',
|
|
51
|
+
"agent": 'Agent::"copiloto"',
|
|
52
|
+
"action": "Tool::query",
|
|
53
|
+
"resource": 'Dataset::"notas"',
|
|
54
|
+
"context": contexto, # os 14 campos
|
|
55
|
+
"entities": entidades, # User, Agent, Workload e o recurso
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
if d["verdict"] == "Deny":
|
|
59
|
+
raise RuntimeError(d["reason_code"])
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Duas camadas
|
|
63
|
+
|
|
64
|
+
A política é avaliada em duas camadas — a do usuário e a do agente — e **as
|
|
65
|
+
duas precisam permitir**. Uma política só com `principal is Agent` responde
|
|
66
|
+
`Deny` com `DeniedNoUserPermit`, e é o erro mais comum do primeiro dia.
|
|
67
|
+
|
|
68
|
+
## Obrigações
|
|
69
|
+
|
|
70
|
+
Quando o veredito é `Transform`, `d["obligations"]` traz o que precisa ser
|
|
71
|
+
feito — `mascarar:cpf,email`, `truncar:100`. **Aplicá-las é responsabilidade de
|
|
72
|
+
quem chamou.** O SDK não toca no dado: ele não viu o conteúdo e não está no
|
|
73
|
+
caminho da resposta.
|
|
74
|
+
|
|
75
|
+
Ignorar uma obrigação é o modo de falha mais silencioso do sistema: o log
|
|
76
|
+
registra que a máscara foi exigida, a auditoria vê que foi exigida, e o dado
|
|
77
|
+
saiu inteiro.
|
|
78
|
+
|
|
79
|
+
## Concorrência
|
|
80
|
+
|
|
81
|
+
Cada instância WASM é single-threaded. Sob concorrência use `PolicyGatePool`,
|
|
82
|
+
que mantém uma instância por thread lógica. Não chame métodos da instância crua
|
|
83
|
+
enquanto outra thread avalia — os buffers são compartilhados.
|
|
84
|
+
|
|
85
|
+
## O hash
|
|
86
|
+
|
|
87
|
+
`gate.version()["artefato_hash"]` é o `sha256` do artefato que foi de fato
|
|
88
|
+
instanciado, e é o mesmo valor que entra em cada decisão como `engine_hash`.
|
|
89
|
+
Compará-lo com o que o painel mostra prova que o binário que decidiu é o que
|
|
90
|
+
você conferiu.
|
|
91
|
+
|
|
92
|
+
## Licença
|
|
93
|
+
|
|
94
|
+
Apache-2.0
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
fazflow_policy_gate/__init__.py
|
|
4
|
+
fazflow_policy_gate/policycore_wasm.wasm
|
|
5
|
+
fazflow_policy_gate.egg-info/PKG-INFO
|
|
6
|
+
fazflow_policy_gate.egg-info/SOURCES.txt
|
|
7
|
+
fazflow_policy_gate.egg-info/dependency_links.txt
|
|
8
|
+
fazflow_policy_gate.egg-info/requires.txt
|
|
9
|
+
fazflow_policy_gate.egg-info/top_level.txt
|
|
10
|
+
tests/test_gate.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
wasmtime>=25
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fazflow_policy_gate
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fazflow-policy-gate"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Governança de agentes de IA: decide cada ação antes que ela aconteça. Binding sobre o artefato WASM, sem lógica de decisão."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "Apache-2.0" }
|
|
8
|
+
keywords = ["ai-agents", "authorization", "policy", "cedar", "governance", "audit"]
|
|
9
|
+
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"License :: OSI Approved :: Apache Software License",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
"Natural Language :: Portuguese (Brazilian)",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# `wasmtime` e a unica. O SDK TypeScript tem zero dependencias porque o Node
|
|
25
|
+
# traz `WebAssembly` embutido; o Python nao traz, e um runtime WASM precisa vir
|
|
26
|
+
# de algum lugar.
|
|
27
|
+
dependencies = ["wasmtime>=25"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://fazflow.com"
|
|
31
|
+
Repository = "https://github.com/JulioCesar1582/FazFlow-IA"
|
|
32
|
+
Issues = "https://github.com/JulioCesar1582/FazFlow-IA/issues"
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["setuptools>=68"]
|
|
36
|
+
build-backend = "setuptools.build_meta"
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.packages.find]
|
|
39
|
+
include = ["fazflow_policy_gate*"]
|
|
40
|
+
|
|
41
|
+
# O artefato viaja DENTRO do pacote.
|
|
42
|
+
#
|
|
43
|
+
# Sem isto, `pip install` entregaria um SDK que na primeira chamada pede um
|
|
44
|
+
# arquivo que a pessoa nao tem — e o beco que este pacote existe para evitar
|
|
45
|
+
# seria o primeiro passo dele.
|
|
46
|
+
[tool.setuptools.package-data]
|
|
47
|
+
fazflow_policy_gate = ["*.wasm"]
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""M4 — testes do binding Python.
|
|
2
|
+
|
|
3
|
+
A51 (paridade entre SDKs) vive em `conformance/diferencial.py`, que compara a
|
|
4
|
+
saida crua deste SDK com a dos outros. Aqui ficam os casos do binding em si,
|
|
5
|
+
mais A54, que so e testavel numa linguagem com threads de verdade.
|
|
6
|
+
|
|
7
|
+
python -m pytest sdk/py/tests -q
|
|
8
|
+
python sdk/py/tests/test_gate.py (sem pytest)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import concurrent.futures
|
|
14
|
+
import time
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
RAIZ = Path(__file__).resolve().parents[3]
|
|
21
|
+
sys.path.insert(0, str(RAIZ / "sdk" / "py"))
|
|
22
|
+
|
|
23
|
+
from fazflow_policy_gate import PolicyGate, PolicyGateError, PolicyGatePool # noqa: E402
|
|
24
|
+
|
|
25
|
+
WASM = RAIZ / "target" / "wasm32-unknown-unknown" / "release" / "policycore_wasm.wasm"
|
|
26
|
+
|
|
27
|
+
BUNDLE = {
|
|
28
|
+
"versao": "v-teste",
|
|
29
|
+
"politicas": """
|
|
30
|
+
@id("u-leitura")
|
|
31
|
+
permit (principal is User, action == Action::"Tool::query", resource is Dataset);
|
|
32
|
+
@id("a-leitura")
|
|
33
|
+
permit (principal is Agent, action == Action::"Tool::query", resource is Dataset);
|
|
34
|
+
@id("mascara")
|
|
35
|
+
@obligation("mascarar:cpf,email")
|
|
36
|
+
permit (principal is User, action == Action::"Tool::query", resource is Dataset)
|
|
37
|
+
when { resource.contem_pessoal };
|
|
38
|
+
""",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def requisicao(**extra):
|
|
43
|
+
contexto = {
|
|
44
|
+
"agora_epoch": 1754661600,
|
|
45
|
+
"hora_local": 14,
|
|
46
|
+
"assurance": 2,
|
|
47
|
+
"delegation_depth": 1,
|
|
48
|
+
"delegation_chain": ["u_8891"],
|
|
49
|
+
"delegation_unique": 1,
|
|
50
|
+
"manifest_hash": "sha256:abc",
|
|
51
|
+
"classificacao": 1,
|
|
52
|
+
"contem_pessoal": False,
|
|
53
|
+
"injection_score": 2,
|
|
54
|
+
"grounding_score": 95,
|
|
55
|
+
"custo_mes_centavos": 1000,
|
|
56
|
+
"origem_input": "usuario",
|
|
57
|
+
"ambiente": "prod",
|
|
58
|
+
}
|
|
59
|
+
contexto.update(extra)
|
|
60
|
+
return {
|
|
61
|
+
"user": 'User::"u_8891"',
|
|
62
|
+
"agent": 'Agent::"bioman-analise"',
|
|
63
|
+
"action": "Tool::query",
|
|
64
|
+
"resource": 'Dataset::"notas"',
|
|
65
|
+
"context": contexto,
|
|
66
|
+
"entities": [
|
|
67
|
+
{"uid": {"type": "User", "id": "u_8891"},
|
|
68
|
+
"attrs": {"departamento": "x", "nivel": 3, "grupos": []}, "parents": []},
|
|
69
|
+
{"uid": {"type": "Workload", "id": "wl"},
|
|
70
|
+
"attrs": {"tier": 2, "agentes_permitidos": []}, "parents": []},
|
|
71
|
+
{"uid": {"type": "Agent", "id": "bioman-analise"},
|
|
72
|
+
"attrs": {"dono": "u_8891", "manifest_hash": "sha256:abc",
|
|
73
|
+
"workload": {"__entity": {"type": "Workload", "id": "wl"}},
|
|
74
|
+
"modo": "enforcing"}, "parents": []},
|
|
75
|
+
{"uid": {"type": "Dataset", "id": "notas"},
|
|
76
|
+
"attrs": {"classificacao": 1, "contem_pessoal": False}, "parents": []},
|
|
77
|
+
],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def abrir():
|
|
82
|
+
gate = PolicyGate.load(WASM)
|
|
83
|
+
gate.load_bundle(BUNDLE)
|
|
84
|
+
return gate
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_caminho_feliz():
|
|
88
|
+
d = abrir().evaluate(requisicao())
|
|
89
|
+
assert d["verdict"] == "Allow"
|
|
90
|
+
assert d["reason_code"] == "PermittedByBoth"
|
|
91
|
+
assert d["matched_user_rules"] == ["u-leitura"]
|
|
92
|
+
assert d["matched_agent_rules"] == ["a-leitura"]
|
|
93
|
+
assert d["obligations"] == []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_a57_float_na_fronteira():
|
|
97
|
+
try:
|
|
98
|
+
abrir().evaluate(requisicao(grounding_score=0.7))
|
|
99
|
+
except PolicyGateError as e:
|
|
100
|
+
assert e.codigo == "FloatNaFronteira"
|
|
101
|
+
else:
|
|
102
|
+
raise AssertionError("R24 — float deveria ser recusado")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_a53_bundle_malformado():
|
|
106
|
+
gate = PolicyGate.load(WASM)
|
|
107
|
+
try:
|
|
108
|
+
gate.load_bundle({"versao": "v1", "politicas": "isto nao e cedar"})
|
|
109
|
+
except PolicyGateError as e:
|
|
110
|
+
assert e.codigo == "BundleInvalido"
|
|
111
|
+
else:
|
|
112
|
+
raise AssertionError("bundle invalido deveria ser recusado")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_r29_campo_ausente_vira_erro_nunca_default():
|
|
116
|
+
req = requisicao()
|
|
117
|
+
del req["context"]["grounding_score"]
|
|
118
|
+
try:
|
|
119
|
+
abrir().evaluate(req)
|
|
120
|
+
except PolicyGateError as e:
|
|
121
|
+
assert e.codigo == "ContextoInvalido"
|
|
122
|
+
else:
|
|
123
|
+
raise AssertionError(
|
|
124
|
+
"o binding NAO DEVE preencher campo ausente: e assim que producao "
|
|
125
|
+
"passa a decidir diferente do que o dev testou"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_a56_r28_hash_do_artefato():
|
|
130
|
+
gate = abrir()
|
|
131
|
+
v = gate.version()
|
|
132
|
+
esperado = "sha256:" + hashlib.sha256(WASM.read_bytes()).hexdigest()
|
|
133
|
+
assert v["artefato_hash"] == esperado
|
|
134
|
+
assert v["schema"] == 1
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_a54_r26_cem_avaliacoes_concorrentes_batem_com_o_serial():
|
|
138
|
+
"""A54 — 100 avaliacoes concorrentes (pool 8) identicas ao serial.
|
|
139
|
+
|
|
140
|
+
Este e o caso que so faz sentido numa linguagem com threads de verdade.
|
|
141
|
+
Se o pool compartilhasse uma instancia, os buffers de entrada e saida se
|
|
142
|
+
embaralhariam entre threads e o resultado sairia trocado — e o teste
|
|
143
|
+
pegaria, porque compara com o resultado serial de cada requisicao.
|
|
144
|
+
"""
|
|
145
|
+
requisicoes = [
|
|
146
|
+
json.dumps(requisicao(classificacao=i % 3, injection_score=i))
|
|
147
|
+
for i in range(100)
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
serial = abrir()
|
|
151
|
+
esperado = [serial.evaluate_raw(r) for r in requisicoes]
|
|
152
|
+
|
|
153
|
+
pool = PolicyGatePool(WASM, BUNDLE, tamanho=8)
|
|
154
|
+
assert pool.tamanho == 8
|
|
155
|
+
|
|
156
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
|
|
157
|
+
obtido = list(ex.map(pool.evaluate_raw, requisicoes))
|
|
158
|
+
|
|
159
|
+
assert obtido == esperado, "R26 — o pool DEVE dar o mesmo resultado do serial"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_r26_tamanho_padrao_e_o_numero_de_cpus():
|
|
163
|
+
import os
|
|
164
|
+
|
|
165
|
+
pool = PolicyGatePool(WASM, BUNDLE)
|
|
166
|
+
assert pool.tamanho == (os.cpu_count() or 1)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_r25_chamadas_intercaladas():
|
|
170
|
+
gate = abrir()
|
|
171
|
+
a = json.dumps(requisicao())
|
|
172
|
+
esperado = gate.evaluate_raw(a)
|
|
173
|
+
outra = json.dumps(requisicao(classificacao=3))
|
|
174
|
+
for i in range(200):
|
|
175
|
+
gate.evaluate_raw(outra)
|
|
176
|
+
assert gate.evaluate_raw(a) == esperado, f"R25 quebrou na iteracao {i}"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_evaluate_sem_bundle():
|
|
180
|
+
try:
|
|
181
|
+
PolicyGate.load(WASM).evaluate(requisicao())
|
|
182
|
+
except PolicyGateError as e:
|
|
183
|
+
assert e.codigo == "SemBundle"
|
|
184
|
+
else:
|
|
185
|
+
raise AssertionError("deveria exigir bundle")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_r26_version_nao_corrompe_avaliacao_concorrente():
|
|
191
|
+
"""R26 — `version()` nao pode entrar numa instancia que outra thread usa.
|
|
192
|
+
|
|
193
|
+
Era `self._todas[0].version()`, alcancando a instancia 0 sem passar pela
|
|
194
|
+
fila de livres. Enquanto uma thread avalia nela, outra chamando `version()`
|
|
195
|
+
escreve na MESMA area de saida do WASM — e o que volta e o que ficou por
|
|
196
|
+
ultimo. Sem erro e sem excecao: veredito trocado, intermitente, sob carga.
|
|
197
|
+
|
|
198
|
+
Um painel que mostra a versao do motor ao lado das decisoes basta.
|
|
199
|
+
"""
|
|
200
|
+
import threading
|
|
201
|
+
|
|
202
|
+
pool = PolicyGatePool(WASM, BUNDLE, tamanho=2)
|
|
203
|
+
erros = []
|
|
204
|
+
parar = threading.Event()
|
|
205
|
+
|
|
206
|
+
# O valor certo, obtido em serie, sem concorrencia nenhuma. Tudo que
|
|
207
|
+
# divergir disto sob carga saiu corrompido.
|
|
208
|
+
esperado = pool.version()
|
|
209
|
+
|
|
210
|
+
def decidindo():
|
|
211
|
+
while not parar.is_set():
|
|
212
|
+
try:
|
|
213
|
+
d = pool.evaluate(requisicao())
|
|
214
|
+
# O veredito deste pedido e conhecido e fixo. Qualquer outra
|
|
215
|
+
# coisa significa que a saida veio corrompida.
|
|
216
|
+
if d.get("verdict") not in ("Allow", "Transform", "Deny", "RequireApproval"):
|
|
217
|
+
erros.append(f"veredito irreconhecivel: {d!r}")
|
|
218
|
+
elif "bundle_version" not in d:
|
|
219
|
+
erros.append(f"decisao sem bundle_version: {d!r}")
|
|
220
|
+
except Exception as e: # noqa: BLE001 — o teste existe para pegar isto
|
|
221
|
+
erros.append(f"avaliacao levantou {type(e).__name__}: {e}")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
def perguntando():
|
|
225
|
+
while not parar.is_set():
|
|
226
|
+
try:
|
|
227
|
+
v = pool.version()
|
|
228
|
+
if v != esperado:
|
|
229
|
+
erros.append(f"version() divergiu do serial: {v!r}")
|
|
230
|
+
except Exception as e: # noqa: BLE001
|
|
231
|
+
erros.append(f"version() levantou {type(e).__name__}: {e}")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
threads = [threading.Thread(target=decidindo) for _ in range(3)]
|
|
235
|
+
threads += [threading.Thread(target=perguntando) for _ in range(3)]
|
|
236
|
+
for t in threads:
|
|
237
|
+
t.start()
|
|
238
|
+
time.sleep(1.5)
|
|
239
|
+
parar.set()
|
|
240
|
+
for t in threads:
|
|
241
|
+
t.join(timeout=10)
|
|
242
|
+
|
|
243
|
+
assert not erros, (
|
|
244
|
+
"R26 — `version()` e `evaluate()` concorrentes se atropelaram na mesma "
|
|
245
|
+
f"instancia WASM. Primeiros erros: {erros[:3]}"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_r23_rollout_repetido_nao_vaza_bundle_no_wasm():
|
|
250
|
+
"""R23 — trocar de bundle nao pode deixar o anterior dentro do WASM.
|
|
251
|
+
|
|
252
|
+
Paridade com o mesmo teste do SDK TypeScript, que mediu 5,7 MB -> 25,2 MB
|
|
253
|
+
em 35 rollouts. `load_bundle` fazia `self._handle = handle` e pronto: o
|
|
254
|
+
bundle antigo continuava no registro do modulo, alcancavel por um handle
|
|
255
|
+
que ninguem mais tinha. `fz_descarregar_bundle` ja estava ligado e so era
|
|
256
|
+
usado pelo `close()`.
|
|
257
|
+
|
|
258
|
+
Mede memoria de verdade, e nao intencao: a memoria linear do WASM so
|
|
259
|
+
CRESCE — nao devolve pagina ao sistema —, entao o vazamento e permanente e
|
|
260
|
+
o sintoma aparece semanas depois como "o gate foi ficando pesado".
|
|
261
|
+
"""
|
|
262
|
+
gate = abrir()
|
|
263
|
+
|
|
264
|
+
def grande(n: int) -> dict[str, str]:
|
|
265
|
+
regras = []
|
|
266
|
+
for i in range(200):
|
|
267
|
+
nivel = (i % 3) + 1
|
|
268
|
+
regras.append(
|
|
269
|
+
'@id("r%d-%d")\n'
|
|
270
|
+
'permit (principal is User, action == Action::"Tool::query", '
|
|
271
|
+
"resource is Dataset)\n"
|
|
272
|
+
"when { context.classificacao <= %d };" % (n, i, nivel)
|
|
273
|
+
)
|
|
274
|
+
return {"versao": "v%d" % n, "politicas": "\n".join(regras)}
|
|
275
|
+
|
|
276
|
+
for i in range(5):
|
|
277
|
+
gate.load_bundle(grande(i))
|
|
278
|
+
aquecido = gate.memoria_em_bytes()
|
|
279
|
+
|
|
280
|
+
for i in range(5, 40):
|
|
281
|
+
gate.load_bundle(grande(i))
|
|
282
|
+
depois = gate.memoria_em_bytes()
|
|
283
|
+
|
|
284
|
+
assert depois == aquecido, (
|
|
285
|
+
"R23 — 35 rollouts a mais levaram a memoria do WASM de %d para %d bytes. "
|
|
286
|
+
"Cada bundle fica no registro do modulo se o anterior nao for "
|
|
287
|
+
"descarregado, e a memoria linear nunca encolhe." % (aquecido, depois)
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
# E o gate continua decidindo com o bundle mais recente: descarregar cedo
|
|
291
|
+
# demais trocaria um vazamento por uma indisponibilidade.
|
|
292
|
+
assert gate.evaluate(requisicao()).get("verdict")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# O descobridor fica no FIM do arquivo, e nao no meio.
|
|
296
|
+
#
|
|
297
|
+
# Python executa o modulo de cima para baixo: com este bloco na linha 188, todo
|
|
298
|
+
# `def test_` escrito DEPOIS dele nunca entrava em `globals()` a tempo de ser
|
|
299
|
+
# coletado. O teste era escrito, o arquivo rodava, a contagem subia um a menos,
|
|
300
|
+
# e nada acusava — a mesma classe de defeito que fazia `npm test` ignorar
|
|
301
|
+
# arquivo novo em `app/`.
|
|
302
|
+
#
|
|
303
|
+
# Um teste que a suite nao executa e um teste que nao existe.
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
testes = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
|
306
|
+
falhas = 0
|
|
307
|
+
for t in testes:
|
|
308
|
+
try:
|
|
309
|
+
t()
|
|
310
|
+
print(f" ok {t.__name__}")
|
|
311
|
+
except Exception as e: # noqa: BLE001
|
|
312
|
+
falhas += 1
|
|
313
|
+
print(f" FALHA {t.__name__}: {e}")
|
|
314
|
+
print(f"\n{len(testes) - falhas}/{len(testes)} passaram")
|
|
315
|
+
raise SystemExit(1 if falhas else 0)
|