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.
@@ -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")
@@ -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
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)