hexgate 0.1.1__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.
Files changed (105) hide show
  1. hexgate-0.1.1/PKG-INFO +1440 -0
  2. hexgate-0.1.1/README.md +1404 -0
  3. hexgate-0.1.1/fortify/__init__.py +78 -0
  4. hexgate-0.1.1/fortify/adapters/__init__.py +0 -0
  5. hexgate-0.1.1/fortify/adapters/google/__init__.py +3 -0
  6. hexgate-0.1.1/fortify/adapters/google/runner.py +106 -0
  7. hexgate-0.1.1/fortify/adapters/google/tools.py +57 -0
  8. hexgate-0.1.1/fortify/adapters/google/wrapper.py +47 -0
  9. hexgate-0.1.1/fortify/adapters/langchain/__init__.py +7 -0
  10. hexgate-0.1.1/fortify/adapters/langchain/agent.py +133 -0
  11. hexgate-0.1.1/fortify/adapters/langchain/tools.py +248 -0
  12. hexgate-0.1.1/fortify/adapters/langchain/wrapper.py +70 -0
  13. hexgate-0.1.1/fortify/adapters/openai/__init__.py +3 -0
  14. hexgate-0.1.1/fortify/adapters/openai/runner.py +125 -0
  15. hexgate-0.1.1/fortify/adapters/openai/tools.py +57 -0
  16. hexgate-0.1.1/fortify/adapters/openai/wrapper.py +47 -0
  17. hexgate-0.1.1/fortify/adapters/pydantic_ai/__init__.py +7 -0
  18. hexgate-0.1.1/fortify/adapters/pydantic_ai/agent.py +111 -0
  19. hexgate-0.1.1/fortify/adapters/pydantic_ai/tools.py +40 -0
  20. hexgate-0.1.1/fortify/adapters/pydantic_ai/wrapper.py +94 -0
  21. hexgate-0.1.1/fortify/agents/__init__.py +57 -0
  22. hexgate-0.1.1/fortify/agents/builtin/__init__.py +1 -0
  23. hexgate-0.1.1/fortify/agents/builtin/researcher/agent.yaml +7 -0
  24. hexgate-0.1.1/fortify/agents/builtin/researcher/policy.yaml +10 -0
  25. hexgate-0.1.1/fortify/agents/builtin/researcher/system.md +5 -0
  26. hexgate-0.1.1/fortify/agents/factory.py +683 -0
  27. hexgate-0.1.1/fortify/agents/loader.py +790 -0
  28. hexgate-0.1.1/fortify/agents/models.py +17 -0
  29. hexgate-0.1.1/fortify/agents/prompts/agent_system.md +14 -0
  30. hexgate-0.1.1/fortify/audit.py +292 -0
  31. hexgate-0.1.1/fortify/bootstrap.py +32 -0
  32. hexgate-0.1.1/fortify/cli/__init__.py +42 -0
  33. hexgate-0.1.1/fortify/cli/_common.py +306 -0
  34. hexgate-0.1.1/fortify/cli/chat.py +282 -0
  35. hexgate-0.1.1/fortify/cli/policy/__init__.py +17 -0
  36. hexgate-0.1.1/fortify/cli/policy/main.py +527 -0
  37. hexgate-0.1.1/fortify/cli/register/__init__.py +8 -0
  38. hexgate-0.1.1/fortify/cli/register/fortify.py +139 -0
  39. hexgate-0.1.1/fortify/cli/register/google.py +104 -0
  40. hexgate-0.1.1/fortify/cli/register/langchain.py +76 -0
  41. hexgate-0.1.1/fortify/cli/register/main.py +113 -0
  42. hexgate-0.1.1/fortify/cli/register/manifest.py +65 -0
  43. hexgate-0.1.1/fortify/cli/register/models.py +107 -0
  44. hexgate-0.1.1/fortify/cli/register/openai.py +78 -0
  45. hexgate-0.1.1/fortify/cli/register/pydantic_ai.py +114 -0
  46. hexgate-0.1.1/fortify/cli/register/register.py +71 -0
  47. hexgate-0.1.1/fortify/cli/serve.py +337 -0
  48. hexgate-0.1.1/fortify/cli/state.py +156 -0
  49. hexgate-0.1.1/fortify/cloud/__init__.py +32 -0
  50. hexgate-0.1.1/fortify/cloud/attenuate.py +105 -0
  51. hexgate-0.1.1/fortify/cloud/biscuit.py +172 -0
  52. hexgate-0.1.1/fortify/cloud/client.py +327 -0
  53. hexgate-0.1.1/fortify/config/__init__.py +1 -0
  54. hexgate-0.1.1/fortify/config/settings.py +50 -0
  55. hexgate-0.1.1/fortify/runtime/__init__.py +53 -0
  56. hexgate-0.1.1/fortify/runtime/command_policy.py +289 -0
  57. hexgate-0.1.1/fortify/runtime/context.py +129 -0
  58. hexgate-0.1.1/fortify/runtime/sandbox_runtime.py +115 -0
  59. hexgate-0.1.1/fortify/runtime/srt.py +88 -0
  60. hexgate-0.1.1/fortify/runtime/workspace.py +330 -0
  61. hexgate-0.1.1/fortify/security/__init__.py +141 -0
  62. hexgate-0.1.1/fortify/security/bundle.py +399 -0
  63. hexgate-0.1.1/fortify/security/constraints.py +252 -0
  64. hexgate-0.1.1/fortify/security/decision.py +145 -0
  65. hexgate-0.1.1/fortify/security/enforcer.py +83 -0
  66. hexgate-0.1.1/fortify/security/errors.py +11 -0
  67. hexgate-0.1.1/fortify/security/file_scope.py +78 -0
  68. hexgate-0.1.1/fortify/security/models.py +59 -0
  69. hexgate-0.1.1/fortify/security/policy.py +182 -0
  70. hexgate-0.1.1/fortify/security/policy_set.py +266 -0
  71. hexgate-0.1.1/fortify/security/rego.py +334 -0
  72. hexgate-0.1.1/fortify/security/rego_wasm.py +213 -0
  73. hexgate-0.1.1/fortify/security/signing.py +122 -0
  74. hexgate-0.1.1/fortify/security/source.py +372 -0
  75. hexgate-0.1.1/fortify/security/wasm_engine.py +486 -0
  76. hexgate-0.1.1/fortify/streaming/__init__.py +57 -0
  77. hexgate-0.1.1/fortify/streaming/events.py +206 -0
  78. hexgate-0.1.1/fortify/streaming/normalize.py +429 -0
  79. hexgate-0.1.1/fortify/tools/__init__.py +21 -0
  80. hexgate-0.1.1/fortify/tools/bash.py +49 -0
  81. hexgate-0.1.1/fortify/tools/decorators.py +158 -0
  82. hexgate-0.1.1/fortify/tools/fetch.py +72 -0
  83. hexgate-0.1.1/fortify/tools/files/__init__.py +9 -0
  84. hexgate-0.1.1/fortify/tools/files/_common.py +35 -0
  85. hexgate-0.1.1/fortify/tools/files/edit_file.py +57 -0
  86. hexgate-0.1.1/fortify/tools/files/glob.py +48 -0
  87. hexgate-0.1.1/fortify/tools/files/grep.py +91 -0
  88. hexgate-0.1.1/fortify/tools/files/read_file.py +39 -0
  89. hexgate-0.1.1/fortify/tools/files/write_file.py +36 -0
  90. hexgate-0.1.1/fortify/tools/refund.py +53 -0
  91. hexgate-0.1.1/fortify/tools/websearch.py +72 -0
  92. hexgate-0.1.1/fortify/tracing/__init__.py +1 -0
  93. hexgate-0.1.1/fortify/tracing/langfuse.py +68 -0
  94. hexgate-0.1.1/fortify/utils/__init__.py +1 -0
  95. hexgate-0.1.1/fortify/utils/retry.py +58 -0
  96. hexgate-0.1.1/hexgate.egg-info/PKG-INFO +1440 -0
  97. hexgate-0.1.1/hexgate.egg-info/SOURCES.txt +103 -0
  98. hexgate-0.1.1/hexgate.egg-info/dependency_links.txt +1 -0
  99. hexgate-0.1.1/hexgate.egg-info/entry_points.txt +2 -0
  100. hexgate-0.1.1/hexgate.egg-info/requires.txt +30 -0
  101. hexgate-0.1.1/hexgate.egg-info/top_level.txt +1 -0
  102. hexgate-0.1.1/pyproject.toml +62 -0
  103. hexgate-0.1.1/setup.cfg +4 -0
  104. hexgate-0.1.1/tests/test_bootstrap.py +37 -0
  105. hexgate-0.1.1/tests/test_demo.py +66 -0
hexgate-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,1440 @@
1
+ Metadata-Version: 2.4
2
+ Name: hexgate
3
+ Version: 0.1.1
4
+ Summary: HexaGate Fortify — authorization infrastructure for AI agents (agent runtime + cloud client).
5
+ Requires-Python: >=3.13
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: bashlex>=0.18
8
+ Requires-Dist: biscuit-python>=0.4
9
+ Requires-Dist: cryptography>=42
10
+ Requires-Dist: httpx>=0.28.1
11
+ Requires-Dist: langchain
12
+ Requires-Dist: langchain-openai
13
+ Requires-Dist: langchain-core
14
+ Requires-Dist: langfuse
15
+ Requires-Dist: pydantic>=2.12.4
16
+ Requires-Dist: python-dotenv>=1.1.1
17
+ Requires-Dist: pyyaml>=6.0.2
18
+ Requires-Dist: rich>=13.9.4
19
+ Requires-Dist: websockets>=13.0
20
+ Requires-Dist: openai-agents>=0.0.10
21
+ Requires-Dist: langgraph>=0.2
22
+ Requires-Dist: nest_asyncio>=1.6
23
+ Requires-Dist: openinference-instrumentation-openai-agents>=0.1
24
+ Requires-Dist: google-adk>=1.0
25
+ Requires-Dist: google-genai>=1.0
26
+ Requires-Dist: litellm>=1.50
27
+ Requires-Dist: openinference-instrumentation-google-adk>=0.1.11
28
+ Requires-Dist: pydantic-ai-slim>=1.88.0
29
+ Requires-Dist: wasmtime>=20.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: ipykernel; extra == "dev"
32
+ Requires-Dist: jupyter; extra == "dev"
33
+ Requires-Dist: pytest>=8.4.1; extra == "dev"
34
+ Requires-Dist: pytest-asyncio>=1.0.0; extra == "dev"
35
+ Requires-Dist: ruff>=0.12.2; extra == "dev"
36
+
37
+ # fortify
38
+
39
+ `fortify` is a lightweight LangChain-based agent runtime built around:
40
+
41
+ - `langchain`
42
+ - `gpt-5.4`
43
+ - `Linkup` web search
44
+ - Tavily-based page fetch
45
+ - `Langfuse` tracing
46
+
47
+ This package is intentionally small. The first milestone is a single assistant with:
48
+
49
+ - `web_search`
50
+ - `fetch`
51
+
52
+ ## 🛠️ Prerequisites
53
+
54
+ The SDK itself only needs Python — but a few of the bundled tools shell out to native binaries that you'll want installed on the host before running an agent that uses them.
55
+
56
+ | Required when you use… | Install |
57
+ |---|---|
58
+ | **`grep`, `glob`, `bash`, `read_file`, `edit_file`, `write_file`** — anything filesystem-shaped | [`ripgrep`](https://github.com/BurntSushi/ripgrep) — `brew install ripgrep` (macOS), `apt install ripgrep` (Debian/Ubuntu), `winget install BurntSushi.ripgrep.MSVC` (Windows) |
59
+ | **The dashboard** under `platform/dashboard/` | Node 18+ and `pnpm` — `corepack enable` or `npm i -g pnpm` |
60
+ | **The control plane** under `platform/api/` | [`uv`](https://docs.astral.sh/uv/) — `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
61
+
62
+ `web_search` and `fetch` have no system dependencies — pure Python. If you're only using those, ignore the table above.
63
+
64
+ The runtime preflights `ripgrep` at agent build time and refuses to start when it's missing — fail-fast is friendlier than silently falling back to a 100× slower path.
65
+
66
+ ## ⚡ Quick Start — Local CLI
67
+
68
+ If you just want to install `fortify` and try the terminal chat:
69
+
70
+ 1. Install the package in editable mode.
71
+ 2. Copy the sample environment file.
72
+ 3. Fill in the required API keys.
73
+ 4. Run the chat CLI against the included local example agent.
74
+
75
+ ```bash
76
+ python -m pip install -e .
77
+ cp .env.sample .env
78
+ fortify chat --agent example_agent
79
+ ```
80
+
81
+ Required keys for the example CLI flow:
82
+
83
+ - `OPENAI_API_KEY`
84
+ - `LINKUP_API_KEY`
85
+ - `TAVILY_API_KEY`
86
+
87
+ Run `fortify --help` to see all subcommands (`chat`, `serve`, `register`), and `fortify <subcommand> --help` for the flags each one accepts.
88
+
89
+ Useful next commands:
90
+
91
+ ```bash
92
+ fortify chat --list-agents
93
+ fortify chat --agent researcher
94
+ fortify chat --use examples/file_agents.py --agent workspace_explorer
95
+ fortify chat --use examples/research_agents.py --agent update_researcher
96
+ ```
97
+
98
+ The included local agent lives in `examples/example_agent/`, and the CLI can also load:
99
+
100
+ - builtin packaged agents like `researcher`
101
+ - code-defined agents registered from `examples/file_agents.py`
102
+ - code-defined research agents registered from `examples/research_agents.py`
103
+
104
+ ## 🚀 Quick Start — Platform
105
+
106
+ To run the full Fortify control plane locally (FastAPI backend + dashboard + your local agent serving over WebSocket), you need **three terminals**. The Makefile has a target that prints the recipe:
107
+
108
+ ```bash
109
+ make demo-platform # prints the 3-terminal recipe below
110
+ ```
111
+
112
+ ```bash
113
+ # Terminal 1 — backend (FastAPI + SQLite on :8000)
114
+ make platform-api
115
+
116
+ # Terminal 2 — dashboard (Vite + React on :5173)
117
+ make dashboard
118
+
119
+ # Terminal 3 — mint a token, then serve your local agent
120
+ # 1. Open http://localhost:5173/tokens, click "Mint new token", copy the value.
121
+ # 2. Add to asianf/.env: FORTIFY_KEY=fty_live_...
122
+ # 3. Pick the agent's Python entrypoint (module:attr — uvicorn-style)
123
+ # and let `fortify serve` take over:
124
+ make serve # default — examples.customer_bot:agent
125
+ # or, for a different agent:
126
+ uv run fortify serve my_app.agents:my_agent
127
+ ```
128
+
129
+ On first serve, `fortify serve` auto-registers the agent's manifest on
130
+ the platform (the server generates a starter role-aware policy from the
131
+ tool list). Subsequent serves short-circuit if the manifest hasn't
132
+ changed. Pass `--no-auto-register` for CI / deliberate-deployment flows.
133
+
134
+ First-time setup (each sub-project has its own deps):
135
+
136
+ ```bash
137
+ make platform-api-install # uv sync inside platform/api/
138
+ make dashboard-install # pnpm install inside platform/dashboard/
139
+ ```
140
+
141
+ Then open http://localhost:5173/playground — type a message, watch the live stream of tool calls and policy decisions from your local agent.
142
+
143
+ ### Audit log (ClickHouse)
144
+
145
+ Policy decisions are written to a local ClickHouse instance for the audit dashboard. Requires Docker.
146
+
147
+ ```bash
148
+ make clickhouse-up # start the server (first run also creates the schema)
149
+ make clickhouse-cli # interactive SQL shell
150
+ make clickhouse-down # stop (keeps data)
151
+ make clickhouse-reset # wipe and recreate (also re-applies the schema)
152
+ ```
153
+
154
+ Schema lives in `platform/clickhouse/init/schema.sql`.
155
+
156
+ **Not a migration system.** That init directory is a POC scaffold — the Docker image runs it exactly once, on first container start with an empty data volume. Editing the SQL after that point is silently ignored on existing environments. To apply schema changes locally, either `make clickhouse-reset` (wipes data) or `make clickhouse-cli` and run the SQL by hand. A real migration runner should replace this directory the first time a second schema change is needed.
157
+
158
+ The service binds to **127.0.0.1 only, on host ports 8124 (HTTP) and 9001 (native)** rather than ClickHouse's default 8123/9000, so it coexists with any other local ClickHouse instance (e.g. a Langfuse-bundled one).
159
+
160
+ <<<<<<< HEAD
161
+ Once both `make clickhouse-up` and `make platform-api` are running, `GET /ready` reports `"clickhouse": "ok"` (the `/health` liveness probe stays dependency-free) and the ingest endpoint `POST /v1/audit/decisions` accepts one decision per request:
162
+
163
+ ```bash
164
+ curl -X POST localhost:8000/v1/audit/decisions \
165
+ -H "Authorization: Bearer fty_test_..." \
166
+ -H "Content-Type: application/json" \
167
+ -d '{"event_id":"9f1e3c5a-4d2b-4b8e-9c8a-1f4e2d8a7c3b",
168
+ "occurred_at":"2026-05-29T14:00:00Z",
169
+ "agent_name":"researcher","tool_name":"read_file","outcome":"deny"}'
170
+ # → 202 {"event_id":"9f1e3c5a-..."}
171
+ ```
172
+
173
+ Integration tests (`pytest -m integration`) round-trip rows through the live ClickHouse — opt-in so the default `make platform-api-test` stays offline-friendly.
174
+
175
+ =======
176
+ >>>>>>> 14c5b6cbdd30aef5d901570e30f486b785327b3b
177
+ The dashboard's `/policies` page lets you edit each agent's policy. `fortify serve` re-fetches at every turn boundary, so your edits take effect on the next chat message without a restart.
178
+
179
+ ## ✨ Core Primitives
180
+
181
+ The two main primitives are:
182
+
183
+ - `create_agent(...)`
184
+ - `@agent_tool(...)`
185
+
186
+ Use them when you want to define everything directly in Python.
187
+
188
+ ```python
189
+ from fortify import agent_tool, create_agent
190
+
191
+
192
+ @agent_tool(name="my_lookup")
193
+ async def my_lookup(query: str) -> dict:
194
+ """Look up something useful."""
195
+ return {"query": query, "results": []}
196
+
197
+ agent, handler = create_agent(
198
+ model="openai:gpt-5.4",
199
+ tools=[my_lookup],
200
+ system_prompt="You are a helpful research assistant.",
201
+ )
202
+ ```
203
+
204
+ ## 🚀 Build an Agent — End to End
205
+
206
+ Devs pick one of two shapes. Both end up at the same enforcement seam — they differ only in **where the policy comes from**.
207
+
208
+ ### Shape A — "I have an existing framework agent"
209
+
210
+ Dev wrote an OpenAI Agents / LangChain / Google ADK / Pydantic AI agent. They wrap it once and they're done:
211
+
212
+ ```python
213
+ from fortify.adapters.openai import FortifyRunner # or .langchain.wrap_langchain_agent, .google.FortifyRunner, .pydantic_ai.wrap_pydantic_agent
214
+ from fortify.runtime import User
215
+
216
+ runner = FortifyRunner() # picks up FORTIFY_KEY from env
217
+ await runner.run(
218
+ my_agent,
219
+ "refund 30",
220
+ user=User(user_id="alice", role="billing"), # per-call scope
221
+ )
222
+ ```
223
+
224
+ That's it. They get:
225
+
226
+ - Tool-call enforcement at every tool boundary (`PolicyEnforcer.decide()`)
227
+ - Role resolution from the active `User.role` at call time
228
+ - Per-request biscuit attenuation
229
+ - Langfuse traces tagged with the caller's identity
230
+
231
+ ### Shape B — "I want the platform to own the agent's YAML"
232
+
233
+ Dev authored the agent's `agent.yaml` / `policy.yaml` / `system.md` in the dashboard. SDK fetches them:
234
+
235
+ ```python
236
+ from fortify import load_fortify_agent, stream_agent, User
237
+
238
+ agent, handler = load_fortify_agent("default") # explicit name — the SDK's loader requires it
239
+
240
+ async with User(user_id="alice", role="billing"):
241
+ async for ev in stream_agent(agent, handler, "refund 30"):
242
+ ...
243
+ ```
244
+
245
+ Same enforcement seam, same `User` scope. The difference is whose system of record holds the YAML — the dev's code vs the dashboard.
246
+
247
+ ### Env vars: that *is* the whole config surface
248
+
249
+ | What dev sets | What changes |
250
+ |---|---|
251
+ | `FORTIFY_KEY=fty_live_<project>_…` | Wakes up the platform path. Without it, adapters / `load_agent` fall back to local / builtin. |
252
+ | `FORTIFY_API_URL=http://localhost:8000` *(optional)* | Platform endpoint. Defaults to localhost. |
253
+ | `FORTIFY_LOCAL_POLICY=./policy.yaml` *or* `./bundle/` | Dev escape hatch: enforce a policy from disk, hot-reload on save. Wins over the platform's bundle. |
254
+ | `FORTIFY_BUNDLE_SIGN_KEY_PATH=./keys/dev.private` *(optional)* | Sign locally-recompiled yaml so `bundle.is_signed` reads True. |
255
+ | `FORTIFY_BUNDLE_PUBKEY_PATH=./keys/prod.public` *(optional)* | Verify a pre-built bundle dir against this pubkey on every reload. |
256
+ | `FORTIFY_BUNDLE_REQUIRE_SIGNATURE=true` *(optional)* | Strict mode — refuse any unsigned or unverifiable bundle at startup. |
257
+
258
+ No config object to instantiate, no `enforce_policy(...)` call to remember on the platform path. The adapter / loader threads it all through.
259
+
260
+ ### Where enforcement actually happens
261
+
262
+ Walk through one tool call:
263
+
264
+ 1. The model emits a tool call. The framework's tool dispatcher invokes the tool.
265
+ 2. The tool is *not the dev's original* — it's a copy our adapter made, whose body starts with `enforcer.decide(role, tool_name, args)`.
266
+ 3. `PolicyEnforcer.decide` reads `self.policy` — that's either a `PolicySet` (pydantic engine, default fallback) or a `PolicyBundle` (WASM engine, what production runs).
267
+ 4. Decision is `allow` → the original tool runs. `deny` → returns a `[policy_denied]` marker the model sees as the tool result. `approval_required` → either calls the dev-supplied approval handler or returns an `[approval_required]` marker.
268
+ 5. **Before step 2, every turn:** `refresh_policy()` calls `self._policy_source.fetch()`. If the source returns a new bundle instance, `enforcer.policy` is swapped in place. Tools don't get re-wrapped — they hold a reference to the enforcer, not the bundle.
269
+
270
+ `_policy_source` is set automatically by the loader based on env:
271
+
272
+ - `FORTIFY_LOCAL_POLICY` set → `YamlPolicySource` or `BundleDirPolicySource` (mtime-driven refresh)
273
+ - `FORTIFY_KEY` set, no local override → `PlatformPolicySource` (ETag / `304 Not Modified` refresh)
274
+ - Neither → no source attached; enforcement uses whatever was loaded once
275
+
276
+ **Scope of the per-turn refresh:** only the policy bundle. `system_prompt`, the manifest's tool list, and the model id are read once at agent construction and stay fixed for the lifetime of the process. Edit those on the dashboard and the change lands at the next `fortify serve` restart — not at the next turn. The split is deliberate: policy is the operator's primary lever (and the one that needs to be auditable per-decision), while the manifest is an author-time concept.
277
+
278
+ ### Two carve-outs worth knowing
279
+
280
+ 1. **Per-call identity stays explicit.** `User` is the one piece the adapter can't infer from env, because it's per-request, not per-process. One line wrapping each call (`user=User(...)` kwarg on adapters, `async with User(...)` for native).
281
+ 2. **`approval_required` tools.** If the policy uses that mode, dev decides what happens — pass `approval_handler=` (True / False / callable) when wrapping. Default for `fortify serve` is auto-approve; for `fortify chat` it prompts the TTY. Native code gets whatever the dev wires.
282
+
283
+ Everything else — fetch, verify, hot-reload, role selection, signature check, decision rendering, tracing — the runtime handles. Set `FORTIFY_KEY` and wrap, or set `FORTIFY_LOCAL_POLICY` and wrap. That's the surface.
284
+
285
+ ## 📦 What You Can Import
286
+
287
+ The current curated surface includes:
288
+
289
+ - `create_agent`
290
+ - `create_manifest`
291
+ - `AgentManifest`
292
+ - `enforce_policy` — accepts an optional `approval_handler=` for `NEEDS_APPROVAL` outcomes
293
+ - `invoke_agent`
294
+ - `stream_agent`
295
+ - `stream_agent_raw`
296
+ - `load_builtin_agent`
297
+ - `list_builtin_agents`
298
+ - `load_fortify_agent`
299
+ - `User` — async context manager for per-request user attenuation (see [User Scope](#-user-scope))
300
+ - `agent_tool`
301
+ - `web_search`
302
+ - `fetch`
303
+
304
+ Example:
305
+
306
+ ```python
307
+ from fortify import (
308
+ create_agent,
309
+ edit_file,
310
+ enforce_policy,
311
+ glob,
312
+ grep,
313
+ read_file,
314
+ write_file,
315
+ agent_tool,
316
+ load_agent,
317
+ load_builtin_agent,
318
+ load_fortify_agent,
319
+ register_agent,
320
+ fetch,
321
+ web_search,
322
+ User,
323
+ )
324
+ ```
325
+
326
+ ## 🤝 Framework Agent Wrapping
327
+
328
+ In addition to its native `create_agent(...)` runtime, `fortify` ships adapters that wrap agents built with **OpenAI Agents SDK**, **LangChain / LangGraph**, **Google ADK**, or **Pydantic AI** to add two things without touching the agent's logic:
329
+
330
+ 1. **Tool-call policy enforcement.** Each tool the agent can invoke is gated by a `PolicyEnforcer` that returns a typed `Decision` (allow / deny / needs-approval) per call. Non-allow outcomes render as a `[policy_denied]` / `[approval_required]` marker the model sees as tool output (or, for pydantic_ai, a `ModelRetry`) rather than aborting the run, so the agent can recover.
331
+ 2. **User-aware observability.** Every run is traced through Langfuse with the active `User`'s identity (user id, session id, role) propagated onto the spans.
332
+
333
+ The four integrations differ in shape because the underlying SDKs do:
334
+
335
+ | | OpenAI Agents SDK | LangChain / LangGraph | Google ADK | Pydantic AI |
336
+ | --- | --- | --- | --- | --- |
337
+ | Entry point | `FortifyRunner` (replaces `Runner`) | `wrap_langchain_agent` (returns a proxy) | `FortifyRunner` (replaces `Runner`) | `wrap_pydantic_agent` (returns a proxy) |
338
+ | Tool wrapping | Copies each `FunctionTool`, replaces `on_invoke_tool` with a `PolicyEnforcer`-gated version | Mutates each `BaseTool` in place (`install_enforcer_on_tool`), replaces `func`/`coroutine` with enforcer-gated versions, sets `handle_tool_error=True` | Copies each `BaseTool` (normalizing bare callables to `FunctionTool`), replaces `run_async` with a gated version | Copies each `Tool` and overrides `function_schema.call` with a gated version |
339
+ | Denial behavior | Returns `decision.as_error_message()` as the tool output (`[policy_denied]` / `[approval_required]` markered string) | Returns `{"ok": False, "error": decision.as_error_payload()}` so LangChain emits the structured dict as the tool result | Returns `decision.as_error_message()` as the tool output | Raises `ModelRetry(decision.as_error_message())`; pydantic_ai surfaces it back to the model as a tool-result message |
340
+ | Tracing | `OpenAIAgentsInstrumentor` + `propagate_attributes` | Langfuse `CallbackHandler` injected into each call's `RunnableConfig` + `propagate_attributes` | `GoogleADKInstrumentor` + `propagate_attributes` | `Agent.instrument_all()` + `propagate_attributes` |
341
+ | Per-call identity | `user: User` keyword on `run` / `run_sync` / `run_streamed` | `user: User` keyword on `invoke` / `ainvoke` / `stream` / `astream` / `astream_events` | `user: User` keyword on `run` / `run_async` | `user: User` keyword on `run` / `run_sync` / `run_stream` / `iter` |
342
+
343
+ Role resolution happens **at call time** from the active `User` contextvar — one wrapped agent serves many users concurrently because the scope is per-call. The original agent object is left intact (or, for LangChain BYO-graph tools, mutated by design so the same `tools` list flows through `create_react_agent`); the wrapper holds the policy.
344
+
345
+ All adapters resolve the API key the same way: from the explicit `api_key=` argument, falling back to the `FORTIFY_KEY` environment variable.
346
+
347
+ ### OpenAI Agents SDK — `FortifyRunner`
348
+
349
+ `FortifyRunner` is a drop-in replacement for `agents.Runner`. It wraps the agent's tools with a `PolicyEnforcer` at construction time and opens a `User` scope around each `Runner.run` / `run_sync` / `run_streamed` call so role resolution happens at call time.
350
+
351
+ ```python
352
+ import asyncio
353
+ from agents import Agent, function_tool
354
+ from dotenv import load_dotenv
355
+
356
+ from fortify.runtime import User
357
+ from fortify.adapters.openai import FortifyRunner
358
+
359
+
360
+ @function_tool
361
+ def get_weather(city: str) -> str:
362
+ return f"{city}: sunny, 23°C"
363
+
364
+
365
+ async def main():
366
+ load_dotenv()
367
+
368
+ agent = Agent(
369
+ name="Weather Agent",
370
+ instructions="Use get_weather when asked about weather.",
371
+ tools=[get_weather],
372
+ model="gpt-4o-mini",
373
+ )
374
+
375
+ runner = FortifyRunner() # picks up FORTIFY_KEY from env
376
+ result = await runner.run(
377
+ agent,
378
+ "What's the weather in Cherbourg?",
379
+ user=User(user_id="user_1", session_id="session_1", role="member"),
380
+ )
381
+ print(result)
382
+
383
+
384
+ if __name__ == "__main__":
385
+ asyncio.run(main())
386
+ ```
387
+
388
+ What happens under the hood:
389
+
390
+ - `FortifyRunner.run` calls `wrap_openai_agent`, which builds a `PolicySet` for `(api_key, agent.name, tool_names)`, constructs one `PolicyEnforcer`, and returns a `dataclasses.replace`'d copy of the agent with policy-gated tool copies — your original `agent` is untouched.
391
+ - The runner opens an `async with user:` scope around the underlying `Runner.run*` call. When the model calls a tool, the guard asks `enforcer.decide(...)` for a `Decision`. On non-allow, it returns `decision.as_error_message()` — a `[policy_denied]` or `[approval_required]` markered string the model can interpret and recover from.
392
+ - The run executes inside `propagate_attributes(user_id=..., session_id=..., metadata={"user_role": ...})`, so Langfuse spans carry the caller identity.
393
+
394
+ `run_sync` and `run_streamed` work the same way.
395
+
396
+ ### LangChain / LangGraph — `wrap_langchain_agent`
397
+
398
+ `wrap_langchain_agent` builds a `PolicyEnforcer` once and installs it on each tool in place (`install_enforcer_on_tool`) so the same instances inside the compiled graph become policy-gated. It returns a `FortifyLangchainAgent` proxy that opens a `User` scope and injects a Langfuse callback into every `invoke` / `ainvoke` / `stream` / `astream` / `astream_events` call. The `user` is supplied **per call**, so a single wrapped agent can serve many users concurrently — role resolution happens at call time from the contextvar.
399
+
400
+ ```python
401
+ import asyncio
402
+ from dotenv import load_dotenv
403
+ from langchain_core.tools import tool
404
+ from langchain_openai import ChatOpenAI
405
+ from langgraph.prebuilt import create_react_agent
406
+
407
+ from fortify.runtime import User
408
+ from fortify.adapters.langchain import wrap_langchain_agent
409
+
410
+
411
+ @tool
412
+ def get_weather(city: str) -> str:
413
+ """Return a weather report for a city."""
414
+ return f"The weather in {city} is 21°C and sunny."
415
+
416
+
417
+ @tool
418
+ def delete_user(user_id: str) -> str:
419
+ """Delete a user account. Destructive."""
420
+ return f"User {user_id} deleted."
421
+
422
+
423
+ TOOLS = [get_weather, delete_user]
424
+
425
+
426
+ async def main():
427
+ load_dotenv()
428
+
429
+ llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
430
+ graph = create_react_agent(llm, TOOLS)
431
+
432
+ agent = wrap_langchain_agent(
433
+ agent=graph,
434
+ tools=TOOLS, # same list passed to create_react_agent — wrapped in place
435
+ api_key="sk-...", # or rely on FORTIFY_KEY
436
+ )
437
+
438
+ result = await agent.ainvoke(
439
+ {"messages": [{"role": "user", "content": "What is the weather in Tokyo?"}]},
440
+ user=User(user_id="langchain_user_1", role="member", session_id="session_abc"),
441
+ )
442
+ print(result)
443
+
444
+
445
+ if __name__ == "__main__":
446
+ asyncio.run(main())
447
+ ```
448
+
449
+ What happens under the hood:
450
+
451
+ - `wrap_langchain_agent` builds a `PolicySet` for the agent, constructs one `PolicyEnforcer(policy_set, agent_name=…)`, and calls `install_enforcer_on_tools(tools, enforcer=…)` to mutate each tool's `func` and `coroutine` with enforcer-gated closures. `handle_tool_error` is forced to `True`. Installation is idempotent — re-installing rebinds the captured originals to the new enforcer without stacking gates.
452
+ - Each invocation method on `FortifyLangchainAgent` takes `user=` and opens an `async with user:` (or `user.sync_scope()` for sync) around the delegated `CompiledStateGraph` call. The active `User` is pushed onto a contextvar; the guards read it at tool-call time to resolve the matching role's policy.
453
+ - A non-allow `Decision` is rendered as `{"ok": False, "error": decision.as_error_payload()}` so the LangChain runtime surfaces the structured dict as the tool result instead of raising.
454
+ - The wrapper also enters `propagate_attributes(user_id=..., session_id=..., metadata={"user_role": ...})` and merges a Langfuse `CallbackHandler` into the `RunnableConfig.callbacks` for the duration of the call. Anything not explicitly proxied falls through via `__getattr__`.
455
+
456
+ ### Google ADK — `FortifyRunner`
457
+
458
+ The Google ADK wrapper exposes its own `FortifyRunner`. It's constructed up front with the agent, app name, and session service (mirroring the ADK `Runner` constructor) — the underlying ADK `Runner` is built once and reused since role resolution happens at call time. `run` / `run_async` then yield ADK events.
459
+
460
+ ```python
461
+ import asyncio
462
+ from datetime import datetime
463
+
464
+ from dotenv import load_dotenv
465
+ from google.adk.agents import Agent
466
+ from google.adk.models.lite_llm import LiteLlm
467
+ from google.adk.sessions import InMemorySessionService
468
+ from google.genai import types
469
+
470
+ from fortify.runtime import User
471
+ from fortify.adapters.google import FortifyRunner
472
+
473
+
474
+ def get_weather(city: str) -> str:
475
+ """Get the current weather for a given city."""
476
+ return f"{city}: sunny, 23°C, humidity 50%, wind 10 m/s"
477
+
478
+
479
+ def get_current_time() -> str:
480
+ """Return the current local time as an ISO-8601 string."""
481
+ return datetime.now().isoformat()
482
+
483
+
484
+ async def main():
485
+ load_dotenv()
486
+
487
+ agent = Agent(
488
+ name="google_runner_example_agent",
489
+ model=LiteLlm(model="openai/gpt-4o"),
490
+ instruction="Use get_current_time and get_weather when asked.",
491
+ tools=[get_current_time, get_weather],
492
+ )
493
+
494
+ user = User(
495
+ user_id="google_user_1",
496
+ session_id="google_session_1",
497
+ role="user",
498
+ )
499
+
500
+ session_service = InMemorySessionService()
501
+ await session_service.create_session(
502
+ app_name="google_runner_example",
503
+ user_id=user.user_id,
504
+ session_id=user.session_id,
505
+ )
506
+
507
+ runner = FortifyRunner(
508
+ agent=agent,
509
+ app_name="google_runner_example",
510
+ session_service=session_service,
511
+ ) # picks up FORTIFY_KEY from env
512
+
513
+ user_msg = types.Content(
514
+ role="user", parts=[types.Part(text="What is the weather in New Delhi?")]
515
+ )
516
+
517
+ async for event in runner.run_async(new_message=user_msg, user=user):
518
+ if event.is_final_response():
519
+ print(event.content.parts[0].text)
520
+
521
+
522
+ if __name__ == "__main__":
523
+ asyncio.run(main())
524
+ ```
525
+
526
+ What happens under the hood:
527
+
528
+ - At construction, `FortifyRunner` calls `wrap_google_agent`, which builds a `PolicySet`, constructs one `PolicyEnforcer`, and returns `agent.model_copy(update={"tools": guarded_tools})` — your original `agent` is untouched.
529
+ - Each tool is normalized first: bare callables in `agent.tools` are wrapped into `FunctionTool` (matching what ADK does internally) so the guard has a stable `BaseTool` surface. Each tool is then `copy.copy`'d and its `run_async` replaced with an enforcer-gated version.
530
+ - Each `run` / `run_async` call opens a `User` scope (`user.sync_scope()` / `async with user:`) and dispatches to the cached underlying `Runner`. On non-allow, the guard returns `decision.as_error_message()` — a `[policy_denied]` or `[approval_required]` markered string — so the ADK runtime forwards it to the model as the tool output instead of aborting the run.
531
+ - Observability is set up lazily on each call: `GoogleADKInstrumentor().instrument()` plus `nest_asyncio.apply()` (ADK's runner spins its own loop), and the run executes inside `propagate_attributes(user_id=..., session_id=..., metadata={"user_role": ...}, tags=["google.runner.run.<agent_name>"])` so Langfuse spans carry the caller identity.
532
+
533
+ ### Pydantic AI — `wrap_pydantic_agent`
534
+
535
+ `wrap_pydantic_agent` returns a `FortifyPydanticAgent` proxy backed by a clone of the original agent whose tools are gated by a freshly built `PolicyEnforcer`. Tools registered via the `Agent(...)` constructor or via `@agent.tool` / `@agent.tool_plain` are all picked up. The `user` is supplied **per call**, so a single wrapped agent can serve many users concurrently — role resolution happens at call time from the contextvar.
536
+
537
+ ```python
538
+ import asyncio
539
+ from dotenv import load_dotenv
540
+ from pydantic_ai import Agent
541
+
542
+ from fortify.runtime import User
543
+ from fortify.adapters.pydantic_ai import wrap_pydantic_agent
544
+
545
+
546
+ async def main():
547
+ load_dotenv()
548
+
549
+ agent = Agent("openai:gpt-4o-mini")
550
+
551
+ @agent.tool_plain
552
+ def get_weather(city: str) -> str:
553
+ """Return a weather report for a city."""
554
+ return f"The weather in {city} is 21°C and sunny."
555
+
556
+ @agent.tool_plain
557
+ def delete_user(user_id: str) -> str:
558
+ """Delete a user account. Destructive."""
559
+ return f"User {user_id} deleted."
560
+
561
+ agent = wrap_pydantic_agent(
562
+ agent=agent,
563
+ api_key="sk-...", # or rely on FORTIFY_KEY
564
+ )
565
+
566
+ result = await agent.run(
567
+ "What is the weather in Tokyo?",
568
+ user=User(
569
+ user_id="pydantic_ai_user_1",
570
+ role="member",
571
+ session_id="pydantic_ai_session_1",
572
+ ),
573
+ )
574
+ print(result.output)
575
+
576
+
577
+ if __name__ == "__main__":
578
+ asyncio.run(main())
579
+ ```
580
+
581
+ What happens under the hood:
582
+
583
+ - `wrap_pydantic_agent` builds a `PolicySet`, constructs one `PolicyEnforcer`, reads tools off the agent's internal `_function_toolset`, copies each tool with an enforcer-gated `function_schema.call`, and returns a shallow-copied agent whose toolset holds those gated copies — your original `agent` is untouched, so it can be reused or wrapped again independently.
584
+ - Each invocation method on `FortifyPydanticAgent` (`run` / `run_sync` / `run_stream` / `iter`) takes `user=` and opens a `User` scope around the delegated `Agent` call. The contextvar is per-task, so concurrent `run` calls for different users do not see each other's policies.
585
+ - A non-allow `Decision` raises `ModelRetry(decision.as_error_message())`; pydantic_ai surfaces it back to the model as a tool-result message — `[policy_denied]` / `[approval_required]` markers in the same shape as the OpenAI/Google adapters — instead of aborting the run.
586
+ - Identity propagation uses `propagate_attributes(...)` so Langfuse spans carry the caller identity. Global tracing is enabled via `Agent.instrument_all()` on construction.
587
+
588
+ ### Runnable examples
589
+
590
+ Working scripts in `examples/`:
591
+
592
+ - `examples/customer_bot.py` — canonical Fortify path: `create_agent(...)` + the dashboard register/serve loop end-to-end.
593
+ - `examples/openai_demo.py` — `FortifyRunner` (OpenAI Agents SDK) end-to-end.
594
+ - `examples/google_demo.py` — `FortifyRunner` (Google ADK) end-to-end with `InMemorySessionService`.
595
+ - `examples/pydantic_ai_demo.py` — `wrap_pydantic_agent` (Pydantic AI) end-to-end.
596
+
597
+ > **Note on naming.** These demo files end in `_demo.py` so their filenames don't shadow the installed packages they import (`agents`, `google`, `langchain`, `openai`, `pydantic_ai`). Without the suffix, running any script inside `examples/` would put the directory on `sys.path[0]` and Python would import the demo files instead of the real packages.
598
+
599
+ ## 🧠 Define Agents In Code
600
+
601
+ You can define agents directly in Python with `create_agent(...)`.
602
+
603
+ If you want the CLI and shared loader to resolve that agent by name, register it first and then load it through `load_agent(...)`.
604
+
605
+ A small end-to-end example registry lives in:
606
+
607
+ - `examples/file_agents.py`
608
+ - `examples/research_agents.py`
609
+
610
+ It demonstrates:
611
+
612
+ - building one agent with `create_agent(...)` only
613
+ - building another with `create_agent(...)` plus `enforce_policy(...)`
614
+ - building a research agent with approval-gated file writes via `enforce_policy(..., approval_handler=...)`
615
+ - registering it with `register_agent(...)`
616
+ - loading it through the shared `load_agent(...)` path
617
+
618
+ For the CLI, you can import that script and then pick one of its registered agents:
619
+
620
+ ```bash
621
+ fortify chat --use examples/file_agents.py --agent workspace_explorer
622
+ fortify chat --use examples/file_agents.py --agent repo_editor
623
+ fortify chat --use examples/research_agents.py --agent update_researcher
624
+ ```
625
+
626
+ ## 🗂️ Builtin And Local Agents
627
+
628
+ The package now ships with a small `fortify.builtin_agents` directory for official starter agents.
629
+
630
+ Current builtin agents:
631
+
632
+ - `researcher`
633
+
634
+ Example:
635
+
636
+ ```python
637
+ from fortify import load_builtin_agent
638
+
639
+ agent, handler = load_builtin_agent("researcher")
640
+ ```
641
+
642
+ The CLI also discovers local agents from:
643
+
644
+ - `./<agent_dir>/agent.yaml`
645
+ - `./agents/<agent_dir>/agent.yaml`
646
+ - `./examples/<agent_dir>/agent.yaml`
647
+
648
+ This repo ships a demo agent at `examples/example_agent/`, so from the project root you can simply run:
649
+
650
+ ```bash
651
+ fortify chat --agent example_agent
652
+ ```
653
+
654
+ ## 🔐 Policy Shape
655
+
656
+ Each tool gets a mode and an optional list of constraints:
657
+
658
+ ```yaml
659
+ version: 1
660
+
661
+ default_policy:
662
+ mode: deny
663
+
664
+ tools:
665
+ web_search:
666
+ mode: allow
667
+ fetch:
668
+ mode: allow
669
+ refund_order:
670
+ mode: allow
671
+ constraints:
672
+ - args.amount <= 500
673
+ - args.currency == "USD"
674
+ ```
675
+
676
+ Supported modes:
677
+
678
+ - `allow`
679
+ - `deny`
680
+ - `approval_required`
681
+
682
+ Constraint operators: `==`, `!=`, `<`, `<=`, `>`, `>=`, `in`, `not in`. Strings on the right use JSON double quotes. Every constraint must pass for the call to authorize (implicit AND). See [User Scope + Roles](#-user-scope--roles) for the role-aware policy bundle shape that picks a per-role policy at call time.
683
+
684
+ ## 🛡️ Tool-Call Policy Enforcement
685
+
686
+ Every tool call routes through a `PolicyEnforcer` that returns `allow` / `deny` / `approval_required`. Deny-by-default; the policy file lists what's allowed.
687
+
688
+ `create_agent(...)` stays close to LangChain. Policy enforcement is applied after agent creation:
689
+
690
+ ```python
691
+ from fortify import AgentPolicy, create_agent, enforce_policy, fetch, web_search
692
+
693
+ policy = AgentPolicy.model_validate(
694
+ {
695
+ "version": 1,
696
+ "default_policy": {"mode": "deny"},
697
+ "tools": {
698
+ "web_search": {"mode": "allow"},
699
+ "fetch": {"mode": "allow"},
700
+ },
701
+ }
702
+ )
703
+
704
+ agent, handler = create_agent(
705
+ model="openai:gpt-5.4",
706
+ tools=[web_search, fetch],
707
+ system_prompt="You are a careful research assistant.",
708
+ )
709
+
710
+ agent = enforce_policy(agent, policy)
711
+ ```
712
+
713
+ `enforce_policy(...)` accepts either:
714
+
715
+ - a Pydantic `AgentPolicy`
716
+ - a YAML file path
717
+
718
+ That means the same agent code can stay simple in development, while deployment systems can inject policy later.
719
+
720
+ `approval_required` is special:
721
+
722
+ - if no approval handler is attached, it behaves like a graceful block — the tool returns a structured `ok: False` result with `error_type: "approval_required"` so the agent can try a fallback instead of crashing
723
+ - if an approval handler is attached (via `enforce_policy(..., approval_handler=...)`), the host can decide whether to allow the action at runtime
724
+
725
+ ## 🧩 Policy Bundles — Compile, Sign, Enforce (WASM)
726
+
727
+ Fortify has **two policy enforcement engines** that return identical decisions (there's a parity test suite that proves it):
728
+
729
+ - **pydantic** (default) — evaluates constraints in-process. Zero setup; this is what every example above uses.
730
+ - **WASM** — compiles `policy.yaml` → Rego → a WebAssembly module evaluated via `wasmtime`. This is the path production ships: one compiled artifact, byte-for-byte reproducible, cryptographically signed by the platform.
731
+
732
+ Why a second engine: the WASM path produces a **portable, signed artifact** (a "bundle"), surfaces **structured deny reasons** (exactly which constraints failed), and chains trust back to the platform's signing key — the same key that signs your biscuit tokens.
733
+
734
+ ### Prerequisite — `opa`
735
+
736
+ The WASM compile step shells out to the [Open Policy Agent](https://www.openpolicyagent.org/) binary. Install it once:
737
+
738
+ ```bash
739
+ brew install opa # macOS
740
+ # or see https://www.openpolicyagent.org/docs/latest/#running-opa
741
+ ```
742
+
743
+ Without `opa` on `PATH`, `fortify policy build --no-wasm` still emits the yaml + rego (no `.wasm`), and the pydantic engine keeps working.
744
+
745
+ ### The `fortify policy` CLI
746
+
747
+ ```bash
748
+ # Validate a policy.yaml without the network — parse + check every constraint
749
+ fortify policy validate policy.yaml
750
+
751
+ # See the Rego your YAML compiles to (stdout)
752
+ fortify policy show-rego policy.yaml
753
+
754
+ # Dry-run a single decision. --engine wasm compiles + evaluates in wasmtime
755
+ # (matching production); the default pydantic engine needs no opa.
756
+ fortify policy test policy.yaml --role billing --tool refund_order \
757
+ --args '{"amount": 200, "currency": "USD"}' --engine wasm
758
+
759
+ # Compile a bundle: writes {stem}.yaml + .rego + .wasm + .bundle.json
760
+ fortify policy build policy.yaml --out ./bundle
761
+ ```
762
+
763
+ On a denied decision, `test` prints the reason; the wasm engine additionally lists each violated constraint string verbatim:
764
+
765
+ ```text
766
+ ✗ DENY · billing → refund_order({"amount": 700})
767
+ reason: Policy denied tool "refund_order": args.amount <= 500
768
+ violations:
769
+ • args.amount <= 500
770
+ ```
771
+
772
+ ### What's in a bundle
773
+
774
+ `fortify policy build` produces a directory:
775
+
776
+ | File | Contents |
777
+ |---|---|
778
+ | `{stem}.yaml` | the source policy (verbatim) |
779
+ | `{stem}.rego` | the compiled Rego module |
780
+ | `{stem}.wasm` | the WebAssembly module — what actually evaluates at runtime |
781
+ | `{stem}.bundle.json` | manifest: sha256 of each artifact + a `wasm_hash` |
782
+ | `{stem}.bundle.json.sig` | detached Ed25519 signature over the manifest (signed bundles only) |
783
+
784
+ The manifest's hashes authenticate the files; the signature authenticates the manifest. Verifying both proves the whole bundle came from the trusted signer, untampered.
785
+
786
+ ### Local enforcement — `FORTIFY_LOCAL_POLICY`
787
+
788
+ Point an agent at a local source and every tool call routes through the WASM engine instead of pydantic — no platform needed. Two shapes are accepted, and both **hot-reload on save** (no restart, no manual rebuild between turns):
789
+
790
+ | `FORTIFY_LOCAL_POLICY=…` | What happens | When to use |
791
+ |---|---|---|
792
+ | **`./bundle/`** (output of `fortify policy build`) | Stat the bundle manifest each turn; reload if its mtime changed. | Production-shaped local testing — exercises the exact signed-bundle path. |
793
+ | **`./policy.yaml`** | Stat the yaml each turn; recompile via `opa` when its mtime changed. | The tight dev loop — edit yaml, save, ask again. No build step. |
794
+
795
+ ```bash
796
+ # Pre-built bundle dir — rebuild it mid-session, next chat picks it up
797
+ fortify policy build policy.yaml --out ./bundle
798
+ FORTIFY_LOCAL_POLICY=./bundle fortify chat --agent researcher
799
+ # [fortify] FORTIFY_LOCAL_POLICY active (bundle-dir): ./bundle (wasm_hash=7e6d1f8b..., unsigned)
800
+
801
+ # Raw yaml — edit policy.yaml in your editor, save, next chat sees the new policy
802
+ FORTIFY_LOCAL_POLICY=./policy.yaml fortify chat --agent researcher
803
+ # [fortify] FORTIFY_LOCAL_POLICY active (yaml): ./policy.yaml (wasm_hash=ab12..., unsigned)
804
+ ```
805
+
806
+ The bundle's integrity (files match the manifest) is verified on every reload — a stale or corrupt bundle fails immediately, not at the first tool call. Yaml sources default to **unsigned**: set `FORTIFY_BUNDLE_SIGN_KEY_PATH=./keys/dev.private` to sign each recompile with your `fortify policy keygen` key, so downstream gates that check `bundle.is_signed` see what they expect.
807
+
808
+ > **Same refresh seam as the platform.** Under the hood both sources implement `PolicySource.fetch()`; the agent runtime calls it at the top of every turn and only swaps the active policy when the returned bundle is a new instance. Unchanged → identity match → no work. That's the same hot-reload path `fortify serve` uses for platform-edited YAML.
809
+
810
+ ### Signing & verification
811
+
812
+ Production bundles are signed so the runtime can prove a bundle is genuine before trusting it. The integrity hash chain catches accidental corruption; the signature catches a malicious author who edits a file *and* updates the manifest to match.
813
+
814
+ Generate a keypair and sign a bundle locally:
815
+
816
+ ```bash
817
+ fortify policy keygen --out ./keys/dev # → dev.private (0600) + dev.public
818
+ fortify policy build policy.yaml --out ./bundle --sign-key ./keys/dev.private
819
+ # → ./bundle/policy.bundle.json.sig
820
+ ```
821
+
822
+ At runtime, point the verifier at the public key:
823
+
824
+ ```bash
825
+ FORTIFY_LOCAL_POLICY=./bundle \
826
+ FORTIFY_BUNDLE_PUBKEY_PATH=./keys/dev.public \
827
+ FORTIFY_BUNDLE_REQUIRE_SIGNATURE=true \
828
+ fortify chat --agent researcher
829
+ # [fortify] FORTIFY_LOCAL_POLICY active (bundle-dir): ./bundle (wasm_hash=..., signed)
830
+ ```
831
+
832
+ `FORTIFY_BUNDLE_REQUIRE_SIGNATURE` controls strictness — warn-by-default keeps local dev frictionless; opt into refusal for CI/prod:
833
+
834
+ | Bundle | `PUBKEY_PATH` set | `REQUIRE_SIGNATURE` | Outcome |
835
+ |---|---|---|---|
836
+ | signed | yes | either | verify; **refuse if it fails** |
837
+ | signed | no | `false` | load with warning (can't verify) |
838
+ | signed | no | `true` | **refuse** (no key to check against) |
839
+ | unsigned | — | `false` (default) | load with warning |
840
+ | unsigned | — | `true` | **refuse** |
841
+
842
+ Keys are raw Ed25519, base64url-encoded — the same format the platform's JWKS endpoint publishes, so production verification reuses the public key your SDK already trusts for biscuit tokens. One root key, two artifacts.
843
+
844
+ > **Keys are gitignored.** `*.private` and `*.pem` are in `.gitignore` so a signing key never lands in version control. Public keys (`*.public`) are safe to commit.
845
+
846
+ ## ✅ Approval-Required Tool Calls
847
+
848
+ Approval handlers are the bridge between static policy and real product interaction. Use them when a tool should be **generally allowed in principle** but only after a user, CLI host, or UI host explicitly approves the specific call.
849
+
850
+ The handler is threaded through `enforce_policy(...)` at wrap time:
851
+
852
+ - `enforce_policy(agent, policy, approval_handler=handler)`
853
+ - `handler` can be:
854
+ - `True` — auto-approve every approval-required call
855
+ - `False` — auto-deny every approval-required call
856
+ - sync function `(action: dict, context: dict | None) -> bool`
857
+ - async function `(action: dict, context: dict | None) -> bool | Awaitable[bool]`
858
+
859
+ The `action` dict carries `{"tool_name", "arguments", "agent_name"}`; `context` is reserved for future host-supplied runtime metadata and is `None` today.
860
+
861
+ Example:
862
+
863
+ ```python
864
+ from fortify import (
865
+ AgentPolicy,
866
+ create_agent,
867
+ edit_file,
868
+ enforce_policy,
869
+ read_file,
870
+ )
871
+
872
+ policy = AgentPolicy.model_validate(
873
+ {
874
+ "version": 1,
875
+ "default_policy": {"mode": "deny"},
876
+ "tools": {
877
+ "read_file": {"mode": "allow"},
878
+ "edit_file": {"mode": "approval_required"},
879
+ },
880
+ }
881
+ )
882
+
883
+ def approval_handler(action: dict, _context: dict | None) -> bool:
884
+ print("approval requested:", action["tool_name"], action["arguments"])
885
+ return True
886
+
887
+ agent, handler = create_agent(
888
+ model="openai:gpt-5.4",
889
+ tools=[read_file, edit_file],
890
+ system_prompt="You are a careful editor.",
891
+ )
892
+
893
+ agent = enforce_policy(agent, policy, approval_handler=approval_handler)
894
+ ```
895
+
896
+ The handler returns a boolean today. Future evolution (richer approval decisions, interrupt/resume flows, UI approval cards, audit metadata) is intentionally left open — the current `(action, context) -> bool` API is enough for CLI and simple hosted apps.
897
+
898
+ ## 🧱 Workspace Sandbox
899
+
900
+ When the `bash` tool executes a command it runs inside an OS-level sandbox configured from the agent's workspace. This is filesystem + network enforcement at the kernel level — a separate concern from policy enforcement, which decides *whether* a tool may be invoked at all.
901
+
902
+ ### Runtime requirement
903
+
904
+ The `bash` tool depends on **`srt`** (Anthropic's `sandbox-runtime`). It wraps each command in `sandbox-exec` + a Seatbelt profile (macOS) or `bubblewrap` + a network namespace + a seccomp filter (Linux).
905
+
906
+ Install before using the `bash` tool:
907
+
908
+ ```bash
909
+ npm install -g @anthropic-ai/sandbox-runtime
910
+ ```
911
+
912
+ Supported on **macOS and Linux only** (Windows is unsupported). If `srt` is not on `PATH`, `run_command` raises `SrtUnavailableError` rather than falling back to unsandboxed execution — *fail closed by design*.
913
+
914
+ ### Configuration
915
+
916
+ Tune the boundary through `LocalWorkspace`:
917
+
918
+ ```python
919
+ from fortify.runtime import LocalWorkspace
920
+
921
+ workspace = LocalWorkspace(
922
+ root_dir="./project",
923
+ allowed_domains=["api.github.com", "*.pypi.org"],
924
+ extra_read_paths=["/etc/ssl"],
925
+ extra_write_paths=["/tmp/build"],
926
+ deny_write_paths=[".env"],
927
+ allow_unix_sockets=["/var/run/docker.sock"],
928
+ allow_local_binding=False,
929
+ extra_env={"NODE_ENV": "test"},
930
+ )
931
+ ```
932
+
933
+ | Knob | What it controls | Default |
934
+ |---|---|---|
935
+ | `root_dir` | Workspace root; reads + writes allowed inside | required |
936
+ | `allowed_domains` | Hostnames the proxy forwards | `()` — no egress |
937
+ | `denied_domains` | Hostnames the proxy refuses | `()` |
938
+ | `extra_read_paths` | Read-only paths beyond the workspace | `()` |
939
+ | `extra_write_paths` | Writable paths beyond workspace + `/tmp` | `()` |
940
+ | `deny_write_paths` | Paths the agent can never write to | `()` |
941
+ | `allow_unix_sockets` | Unix sockets the agent can `connect()` | `()` — no IPC |
942
+ | `allow_local_binding` | Whether the agent can `bind(127.0.0.1, …)` | `False` |
943
+ | `extra_env` | Env vars passed into the sandbox | `{}` |
944
+
945
+ Defaults add up to: no network egress, no IPC sockets, no localhost bind, reads allowed inside the workspace and on system paths but not `$HOME`, writes allowed only inside the workspace + `/tmp`.
946
+
947
+ `allowUnixSockets` and `allowLocalBinding` exist because they're the two ways traffic can leave the proxy lane (Unix-domain IPC and inbound localhost). Default-deny on both; opt in per-deployment when you actually need docker-socket access, a local dev server, etc.
948
+
949
+ ### Env scrubbing
950
+
951
+ The sandboxed child does **not** inherit the parent process's environment. Only an explicit allowlist passes through:
952
+
953
+ - `PATH` (curated baseline including `/opt/homebrew/bin` for Apple Silicon)
954
+ - `HOME` (set to the workspace root, so cache writes land inside `allowWrite`)
955
+ - `TMPDIR`, `TERM`
956
+ - Locale keys: `LANG`, `LC_ALL`, `LC_CTYPE`, `LC_COLLATE`, `LC_MESSAGES`
957
+ - Anything operator-supplied via `extra_env`
958
+
959
+ This means parent-process secrets — `AWS_SECRET_ACCESS_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GH_TOKEN`, `SSH_AUTH_SOCK`, etc. — **don't leak** into the agent. Tools that legitimately need credentials should receive them through `extra_env`, where you control exactly what's passed.
960
+
961
+ ### Layering with policy + approval
962
+
963
+ | Layer | Question | Mechanism |
964
+ |---|---|---|
965
+ | **Policy** | Is this tool allowed at all for this caller's role? | adapter / `enforce_policy(...)` |
966
+ | **Approval** | Should this specific call go ahead? | `enforce_policy(..., approval_handler=...)` |
967
+ | **Sandbox** | What can the spawned shell actually do? | OS-level via `srt` |
968
+
969
+ Policy decides whether the `bash` tool is callable. The approval handler inspects each call gated by `approval_required`. The sandbox bounds reach *if a call does run*. They're complementary — deploy whichever combination matches your threat model.
970
+
971
+ ### What the sandbox does NOT do
972
+
973
+ Worth being explicit about the gaps so operators know where to layer their own checks:
974
+
975
+ - **Resource limits.** No CPU/memory/fork caps. A fork-bomb runs to completion. Use cgroups or `ulimit` if that matters.
976
+ - **Command-string semantics.** `srt` sees `sh -c "<command>"` as an opaque arg. The sandbox bounds *reach*, not intent — `rm -rf <workspace>` is permitted because the workspace is in `allowWrite`.
977
+ - **Inside-sandbox actions.** The sandbox stops the agent from exfiltrating a workspace file over the network or writing outside the boundary, but doesn't reason about what the agent does *within* the boundary.
978
+
979
+ ## 🔧 Environment
980
+
981
+ Copy `.env.sample` to `.env` and set:
982
+
983
+ - `OPENAI_API_KEY`
984
+ - `LINKUP_API_KEY`
985
+ - `TAVILY_API_KEY`
986
+ - `LANGFUSE_SECRET_KEY`
987
+ - `LANGFUSE_PUBLIC_KEY`
988
+ - optional `LANGFUSE_HOST`
989
+
990
+ Policy-bundle enforcement (see [Policy Bundles](#-policy-bundles--compile-sign-enforce-wasm)) reads a few more, all optional:
991
+
992
+ | Env var | Purpose |
993
+ |---|---|
994
+ | `FORTIFY_LOCAL_POLICY` | Path to a bundle directory **or** a `policy.yaml`; routes enforcement through the WASM engine and hot-reloads on save |
995
+ | `FORTIFY_BUNDLE_PUBKEY_PATH` | base64url Ed25519 public key used to verify a bundle's signature |
996
+ | `FORTIFY_BUNDLE_SIGN_KEY_PATH` | base64url Ed25519 private key used to sign locally-compiled yaml sources (so `bundle.is_signed` is True) |
997
+ | `FORTIFY_BUNDLE_REQUIRE_SIGNATURE` | `true` to refuse unsigned or unverifiable bundles (default: warn only) |
998
+ | `FORTIFY_OPA_BIN` | Override the `opa` binary location (default: search `PATH`) |
999
+
1000
+ ## 🧪 Tests & Dev Tooling
1001
+
1002
+ A `Makefile` at the repo root wraps the day-to-day commands so you don't have to remember the `uv` incantations.
1003
+
1004
+ ```bash
1005
+ make help # list every target with descriptions
1006
+ make install-dev # uv sync --extra dev (first time only)
1007
+ make test # full SDK test suite, quiet
1008
+ make check # lint + fmt-check + test (matches CI)
1009
+ make test-one T=tests/security/test_bundle.py # single file
1010
+ ```
1011
+
1012
+ Targets at a glance:
1013
+
1014
+ | Target | What it runs |
1015
+ |---|---|
1016
+ | **SDK dev loop** | |
1017
+ | `test` / `test-verbose` / `test-failed` / `test-one` | `pytest tests/` with various flags |
1018
+ | `lint` / `lint-fix` | `ruff check` (with `--fix` for autofixes) |
1019
+ | `fmt` / `fmt-check` | `ruff format` |
1020
+ | `check` | `lint` + `fmt-check` + `test` — pre-push gate |
1021
+ | **M2 policy demo** | |
1022
+ | `policy-build` | Compile the example policy.yaml to a bundle |
1023
+ | `policy-test-wasm` | Smoke a WASM-engine decision |
1024
+ | `demo-override` | Build a deny bundle + chat with `FORTIFY_LOCAL_POLICY` |
1025
+ | **Platform demo** (multi-terminal — see `make demo-platform`) | |
1026
+ | `platform-api` / `platform-api-install` / `platform-api-test` | FastAPI control plane in `platform/api/` |
1027
+ | `dashboard` / `dashboard-install` | Vite + React app in `platform/dashboard/` |
1028
+ | `serve` | `fortify serve` — bridge this SDK to the platform |
1029
+ | `demo-platform` | Print the 3-terminal recipe |
1030
+ | **Misc** | |
1031
+ | `build` / `clean` | Package + tidy |
1032
+
1033
+ By default `uv` manages its own `.venv` (created by `make install-dev`). If you keep your dev environment elsewhere — e.g. a `micromamba` env — point `uv` at it once and `make` picks it up:
1034
+
1035
+ ```bash
1036
+ export UV_PROJECT_ENVIRONMENT=/Users/<you>/micromamba/envs/<your-env>
1037
+ uv sync --extra dev # one-time: install dev deps into that env
1038
+ make test # now runs against the micromamba env
1039
+ ```
1040
+
1041
+ Drop the `export` into your shell rc (or a `direnv` `.envrc`) and forget about it. Without `--extra dev`, `pytest-asyncio` is missing and you'll see *"async functions are not natively supported"* across every async test — same trap on a fresh env.
1042
+
1043
+ The platform-side test suite is separate and lives at `platform/api/tests/`:
1044
+
1045
+ ```bash
1046
+ cd platform/api && uv run pytest tests/
1047
+ ```
1048
+
1049
+ ## ▶️ Run It
1050
+
1051
+ Install the package into your current environment:
1052
+
1053
+ ```bash
1054
+ python -m pip install -e .
1055
+ ```
1056
+
1057
+ Run the config-driven demo:
1058
+
1059
+ ```bash
1060
+ python examples/demo.py
1061
+ ```
1062
+
1063
+ Run the inline chat CLI with a local or builtin YAML agent:
1064
+
1065
+ ```bash
1066
+ fortify chat --agent example_agent
1067
+ ```
1068
+
1069
+ Run the CLI with code-defined agents from a Python script:
1070
+
1071
+ ```bash
1072
+ fortify chat --use examples/file_agents.py --agent workspace_explorer
1073
+ fortify chat --use examples/file_agents.py --agent repo_editor
1074
+ fortify chat --use examples/research_agents.py --agent update_researcher
1075
+ fortify chat --use examples/research_agents.py --agent update_researcher --approval-mode ask
1076
+ ```
1077
+
1078
+ List what the CLI can currently resolve:
1079
+
1080
+ ```bash
1081
+ fortify chat --list-agents
1082
+ ```
1083
+
1084
+ ### `fortify register` — push a manifest to the platform
1085
+
1086
+ Register a code-defined agent's manifest with the Fortify platform. `--agent`
1087
+ takes a Python import path of the form `module.path:attribute`, the same shape
1088
+ as ASGI/WSGI entrypoints. The CLI imports the module, grabs the agent object,
1089
+ and POSTs its manifest to `${FORTIFY_API_URL}/v1/agents` using
1090
+ `${FORTIFY_KEY}` as the bearer token:
1091
+
1092
+ ```bash
1093
+ fortify register --agent examples.customer_bot:agent
1094
+ fortify register --agent my_app.agents:my_agent --description "Customer support bot"
1095
+ ```
1096
+
1097
+ On first register, the platform auto-generates a starter role-aware
1098
+ policy from the manifest's tool list (`read_only` mixin + `default` +
1099
+ `member` + `admin`) and signs a WASM bundle so `fortify serve` runs
1100
+ against real enforcement from the first request. Edit the policy in
1101
+ the dashboard's `/policies` page; subsequent re-registers preserve
1102
+ those edits — only the manifest snapshot grows.
1103
+
1104
+ LangGraph compiled graphs don't expose their tool nodes — nor the model or
1105
+ system prompt baked into them — after compilation, so when registering one
1106
+ you can pass each of those pieces explicitly. Only `--tools` is required;
1107
+ `--model` and `--system-prompt` are optional and just populate the matching
1108
+ fields on the manifest so the dashboard can show them:
1109
+
1110
+ ```bash
1111
+ fortify register \
1112
+ --agent my_app.agents:graph \
1113
+ --tools my_app.tools:my_tools \
1114
+ --model gpt-4o-mini \
1115
+ --system-prompt prompts/support.md
1116
+ ```
1117
+
1118
+ For everyone else — agents built with `fortify.create_agent(...)`, OpenAI
1119
+ Agents, Pydantic AI, Google ADK — the manifest reads tools, model, and
1120
+ system prompt directly off the object. No flags needed.
1121
+
1122
+ `--system-prompt` accepts either a literal string or a path to a `.md` /
1123
+ `.txt` / `.jinja` file (read as text at register time).
1124
+
1125
+ Supported frameworks: OpenAI Agents SDK, Google ADK, Pydantic AI, LangChain/LangGraph, Fortify agents.
1126
+
1127
+ ### `fortify serve` — bridge a local agent to the platform's relay
1128
+
1129
+ `fortify serve` takes the **same** `module:attr` spec as `fortify register`.
1130
+ The CLI imports the agent, derives the manifest in one call, auto-registers
1131
+ on the platform (idempotent — content-hash short-circuits no-ops), fetches
1132
+ the operator's policy from the cloud, and opens the WebSocket relay so the
1133
+ dashboard's Playground can drive it. Policy edits in `/policies` take
1134
+ effect at the next chat-turn boundary.
1135
+
1136
+ ```bash
1137
+ fortify serve examples.customer_bot:agent
1138
+
1139
+ # CI / deliberate-deploy: error if not pre-registered
1140
+ fortify serve examples.customer_bot:agent --no-auto-register
1141
+ ```
1142
+
1143
+ There is **no** `FORTIFY_AGENT_NAME` env var anymore — the name lives in
1144
+ the agent's `.name` attribute (or the `name=` kwarg you passed to
1145
+ `create_react_agent` / `create_agent`). The platform is the source of
1146
+ truth for policy; your Python file is the source of truth for code.
1147
+
1148
+ ### Build A Manifest Programmatically — `create_manifest`
1149
+
1150
+ If you need the manifest object without POSTing it to the platform — to
1151
+ inspect it, persist it elsewhere, diff it across versions, or wire it
1152
+ into a custom registration flow — call `create_manifest` directly:
1153
+
1154
+ ```python
1155
+ from fortify import create_manifest
1156
+
1157
+ manifest = create_manifest(agent, description="Customer support bot")
1158
+ print(manifest.model_dump())
1159
+ ```
1160
+
1161
+ `create_manifest` dispatches on the framework of `agent`. The supported
1162
+ types are the same set `fortify register` accepts: Fortify, OpenAI Agents
1163
+ SDK, Google ADK, Pydantic AI, and LangChain/LangGraph compiled graphs.
1164
+ For LangGraph you must pass `tools=` explicitly, and may pass `model=` /
1165
+ `system_prompt=`, since compiled graphs don't expose those fields after
1166
+ compilation.
1167
+
1168
+ The return value is an `AgentManifest` (a Pydantic model, also re-exported
1169
+ from `fortify` for type annotations) — the same schema the platform
1170
+ stores and the dashboard renders.
1171
+
1172
+ ## 🌐 Fortify Platform
1173
+
1174
+ The `platform/` directory contains an optional control plane that hosts agent definitions, dev tokens, and a live debug surface. The SDK works fully without it (`load_local_agent`, `load_builtin_agent` keep their existing semantics) — but with it you get:
1175
+
1176
+ - A web dashboard for editing agent YAMLs and viewing the project graph
1177
+ - Mintable dev tokens (`fty_test_*`, `fty_live_*`) that authenticate the SDK
1178
+ - A live Playground that streams tool calls and decisions from your running agent
1179
+ - **Turn-level policy refresh** — edit YAML in the UI, the next chat picks it up
1180
+
1181
+ ### Backend (`platform/api/`)
1182
+
1183
+ FastAPI over SQLite. Run with:
1184
+
1185
+ ```bash
1186
+ cd platform/api
1187
+ uv run uvicorn main:app --reload --port 8000
1188
+ ```
1189
+
1190
+ The default `support-bot` project is seeded on first boot with two agents — `default` (broad access, side-effects gated by `approval_required`) and `read_only` (everything mutating denied).
1191
+
1192
+ Endpoints:
1193
+
1194
+ - `POST /v1/projects/:id/tokens` — mint a dev token (returned in full once)
1195
+ - `GET /v1/projects/:id/tokens` — list dev tokens (masked)
1196
+ - `DELETE /v1/projects/:id/tokens/:tid` — revoke
1197
+ - `GET /v1/projects/:id/agents` — list agents with their YAMLs
1198
+ - `GET /v1/projects/:id/agents/:name` — read one agent
1199
+ - `PUT /v1/projects/:id/agents/:name` — save agent / policy / system YAMLs
1200
+ - `WS /v1/projects/:id/serve` — producer socket (the `fortify serve` CLI dials here)
1201
+ - `WS /v1/projects/:id/chat` — consumer socket (the dashboard Playground dials here)
1202
+
1203
+ DB lives at `platform/api/fortify.db`. Delete it and restart to wipe state.
1204
+
1205
+ ### Dashboard (`platform/dashboard/`)
1206
+
1207
+ Vite + React + Tailwind + shadcn/ui + React Flow.
1208
+
1209
+ ```bash
1210
+ cd platform/dashboard
1211
+ pnpm install # first time
1212
+ pnpm dev
1213
+ ```
1214
+
1215
+ Routes:
1216
+
1217
+ - `/` — overview KPIs
1218
+ - `/agents` — file-tree YAML editor + live mini-graph per agent
1219
+ - `/graph` — read-only project overview (everyone → agents → tools)
1220
+ - `/playground` — chat with a serving agent, watch tool decisions stream live
1221
+ - `/tokens` — mint, list, revoke dev tokens
1222
+ - `/settings` — project settings
1223
+
1224
+ The dev server proxies `/v1/*` (HTTP and WebSocket) to `localhost:8000`, so HMR works through the same origin as the API.
1225
+
1226
+ ### Serve Mode (`fortify serve`)
1227
+
1228
+ Bridges your local agent runtime to the dashboard via the platform's WebSocket relay — same pattern as Cloudflare Tunnel or ngrok.
1229
+
1230
+ ```bash
1231
+ # in asianf/.env
1232
+ FORTIFY_KEY=fty_live_<project>_<biscuit>
1233
+ FORTIFY_API_URL=http://localhost:8000 # optional, defaults to localhost:8000
1234
+
1235
+ # pick an agent module:attr — uvicorn-style spec
1236
+ uv run fortify serve examples.customer_bot:agent
1237
+ ```
1238
+
1239
+ Behaviour:
1240
+
1241
+ - Loads the agent object from the `module:attr` spec — same form as
1242
+ `fortify register`. The agent's name, tools, model, and system
1243
+ prompt come from the object directly (no flags duplicating
1244
+ what's already in code).
1245
+ - Auto-registers the manifest on first run via `POST /v1/agents`
1246
+ (idempotent — content-hash short-circuits no-ops). Skip with
1247
+ `--no-auto-register` for CI / deliberate deployments.
1248
+ - Fetches the operator's policy from `GET /v1/agents/{name}`. Local
1249
+ code is authoritative for code; the platform is authoritative for
1250
+ policy.
1251
+ - Connects `wss://${FORTIFY_API_URL}/v1/serve` with the bearer
1252
+ percent-encoded into the WebSocket subprotocol (Phase 6 — the WS
1253
+ handshake grammar doesn't allow `=` padding in plain headers).
1254
+ Server echoes `fortify.v1` to confirm the contract.
1255
+ - Sends a `hello` frame announcing the agent name (the dashboard's
1256
+ "Serving" indicator reads this).
1257
+ - On each inbound `chat` message, **refreshes the active policy**
1258
+ before running. Refresh is an `If-None-Match` round-trip to the
1259
+ platform: a 304 reuses the cached WASM module, a 200 swaps in the
1260
+ new bundle. Dashboard edits take effect at turn boundaries without
1261
+ restarting the process or re-wrapping the tools.
1262
+ - Streams every `StreamEvent` (text deltas, tool start/end, run end)
1263
+ back as JSON.
1264
+ - Auto-approves any `approval_required` tools — there's no TTY in
1265
+ serve mode for prompts (planned: dashboard-side approval UI).
1266
+ - Reconnects with exponential backoff on socket drop.
1267
+
1268
+ There's no longer a `FORTIFY_AGENT_NAME` env var, `--agent` flag, or
1269
+ `--use` flag — the spec carries everything. If you've been setting
1270
+ `FORTIFY_AGENT_NAME` in `.env`, drop it.
1271
+
1272
+ ### How `load_agent()` resolves with `FORTIFY_KEY`
1273
+
1274
+ ```python
1275
+ from fortify import load_agent
1276
+
1277
+ agent, handler = load_agent("read_only") # explicit name required
1278
+ ```
1279
+
1280
+ When `FORTIFY_KEY` is set, `load_agent(name)` fetches the named agent from
1281
+ the platform (via `load_fortify_agent`). When `FORTIFY_KEY` is not set, it
1282
+ falls back to local / registered / builtin resolution — no platform call.
1283
+
1284
+ The legacy `FORTIFY_AGENT_NAME` env-var fallback was removed in Phase 7;
1285
+ direct callers of `load_fortify_agent` / `load_agent` must pass an
1286
+ explicit name. For the CLI workflow, `fortify serve <module:attr>` derives
1287
+ the name from the loaded agent's `.name` attribute — no env var needed.
1288
+
1289
+ ## 👤 User Scope + Roles
1290
+
1291
+ Real backends serve many users, and different users get different capabilities. Fortify splits that into two pieces:
1292
+
1293
+ - **`User`** — the per-request scope. Marks "this invocation acts on behalf of alice, in role X." Async context manager; pushes a fact-bearing Biscuit through the agent runtime.
1294
+ - **Role policies** — one `policy.yaml` per role, optionally inheriting from a base mixin. The runtime picks the right one at call time based on the active `User.role`.
1295
+
1296
+ The two are deliberately decoupled: tokens carry **identity** (who is calling), policy files carry **rules** (what they can do).
1297
+
1298
+ ### Minimal example
1299
+
1300
+ ```python
1301
+ from fortify import User, load_fortify_agent, stream_agent
1302
+
1303
+ agent, handler = load_fortify_agent("support-bot") # client + roles attached at load
1304
+
1305
+ async with User(user_id="alice", role="billing", ttl_seconds=300):
1306
+ async for event in stream_agent(agent, handler, "refund customer 30"):
1307
+ ...
1308
+ ```
1309
+
1310
+ That's it — no manual `attenuate_for_user`, `extract_facts`, or `ToolUseContext` plumbing at the call site. The runtime mints the per-request token, picks the `billing` role's policy file, and evaluates its constraints against each tool call.
1311
+
1312
+ ### FastAPI middleware pattern
1313
+
1314
+ The cleanest production shape — set the scope once in middleware, every endpoint runs in the right user's role:
1315
+
1316
+ ```python
1317
+ from fastapi import FastAPI
1318
+ from fortify import User, load_fortify_agent, stream_agent
1319
+
1320
+ app = FastAPI()
1321
+ agent, handler = load_fortify_agent("support-bot") # at startup
1322
+
1323
+ @app.middleware("http")
1324
+ async def attach_user(request, call_next):
1325
+ auth = await authenticate(request) # your auth
1326
+ async with User(
1327
+ user_id=auth.id,
1328
+ role=auth.role, # e.g. "billing"
1329
+ session_id=request.state.session_id,
1330
+ ttl_seconds=300,
1331
+ ):
1332
+ return await call_next(request)
1333
+
1334
+ @app.post("/chat")
1335
+ async def chat(req):
1336
+ async for event in stream_agent(agent, handler, req.message):
1337
+ yield event # already scoped
1338
+ ```
1339
+
1340
+ ### `User` fields
1341
+
1342
+ | Field | Type | Required | Effect |
1343
+ |---|---|---|---|
1344
+ | `user_id` | `str` | ✅ | Becomes `user("alice")` in the attenuated Biscuit. |
1345
+ | `role` | `str?` | optional | Becomes `role("billing")` in the Biscuit. Selects which role policy file applies at tool-call time. Fall-back: the `default` role. |
1346
+ | `session_id` | `str?` | optional | Trace tagging — surfaces on Langfuse spans. |
1347
+ | `ttl_seconds` | `int?` | optional | Embeds a `check if time($t), $t < now+ttl` predicate so the token can't outlive the request. |
1348
+
1349
+ ### Role policies — one file per role
1350
+
1351
+ Agents that need per-role behaviour ship a `policies/` directory instead of a single `policy.yaml`:
1352
+
1353
+ ```text
1354
+ agent/
1355
+ ├── agent.yaml
1356
+ ├── system.md
1357
+ └── policies/
1358
+ ├── default.yaml # fallback when User.role is None / unknown
1359
+ ├── read_only.yaml # mixin — is_mixin: true
1360
+ ├── support.yaml # inherits: [read_only]
1361
+ └── billing.yaml # inherits: [read_only, support]
1362
+ ```
1363
+
1364
+ Each role file is a complete `AgentPolicy`. Inheritance is left-to-right, child wins on conflicts:
1365
+
1366
+ ```yaml
1367
+ # policies/read_only.yaml (mixin — safe base)
1368
+ version: 1
1369
+ is_mixin: true
1370
+ default_policy:
1371
+ mode: deny
1372
+ tools:
1373
+ view_orders: { mode: allow }
1374
+ list_tickets: { mode: allow }
1375
+ ```
1376
+
1377
+ ```yaml
1378
+ # policies/billing.yaml
1379
+ version: 1
1380
+ inherits: [read_only]
1381
+ tools:
1382
+ refund_order:
1383
+ mode: allow
1384
+ constraints:
1385
+ - args.amount <= 500
1386
+ - args.currency == "USD"
1387
+ wire_transfer:
1388
+ mode: approval_required
1389
+ constraints:
1390
+ - args.amount <= 100000
1391
+ ```
1392
+
1393
+ ### Constraints — Rego-compatible expressions
1394
+
1395
+ Each tool can carry a `constraints:` list of string expressions evaluated against the call's arguments. Every constraint must pass for the call to authorize (implicit AND).
1396
+
1397
+ | Operator | Example | Notes |
1398
+ |---|---|---|
1399
+ | `==` `!=` | `args.currency == "USD"` | Strings use JSON double quotes |
1400
+ | `<` `<=` `>` `>=` | `args.amount <= 500` | Type-mismatched comparisons fail-closed |
1401
+ | `in` | `args.template in ["a", "b"]` | RHS must be a JSON list |
1402
+ | `not in` | `args.priority not in ["urgent"]` | Two-word operator, treated as one |
1403
+
1404
+ Constraints are Rego conditions by design: the [WASM engine](#-policy-bundles--compile-sign-enforce-wasm) compiles them to OPA Rego unchanged, and the pydantic engine evaluates the same strings in-process — both produce identical decisions. To compose with AND, emit multiple lines; to compose with OR, emit two tools or two roles.
1405
+
1406
+ ### Policy + role end-to-end
1407
+
1408
+ With the `billing.yaml` policy above and `async with User(user_id="alice", role="billing")`:
1409
+
1410
+ - `refund_order(amount=200, currency="USD")` → ✅ allowed
1411
+ - `refund_order(amount=600, currency="USD")` → ❌ denied — constraint `args.amount <= 500`
1412
+ - `refund_order(amount=200, currency="EUR")` → ❌ denied — constraint `args.currency == "USD"`
1413
+ - `wire_transfer(amount=50000)` → ✋ requires approval (mode = `approval_required`)
1414
+
1415
+ Switch to `User(user_id="alice", role="default")` and `refund_order` itself is missing from the policy — falls through to the `default_policy.mode` (deny).
1416
+
1417
+ ### Notes
1418
+
1419
+ - **Single-file policies still work.** A legacy `policy.yaml` is treated as the `default` role — no migration needed for agents that don't yet differentiate by role.
1420
+ - **Lazy attenuation.** `User.__aenter__` only pushes a contextvar — the cryptographic work happens inside `stream_agent` / `invoke_agent` the first time the agent runs. Errors surface at first agent call, not at scope entry.
1421
+ - **Local agents skip attenuation.** A `User` scope around a `load_local_agent` / `load_builtin_agent` agent logs a warning and runs with no facts. The `default` policy still applies — use `load_fortify_agent` for the full signed chain.
1422
+ - **Explicit override.** Passing `tool_use_context=` explicitly to `stream_agent` / `invoke_agent` wins over an active `User` scope. Useful for tests or one-off bypass.
1423
+ - **Sync callers.** `User` exposes both `async with user:` and `user.sync_scope()`. The async form is the primary API (room for KMS / audit / JWKS I/O in `__aenter__` / `__aexit__` later); the sync mirror exists for CLI loops and `Runner.run_sync`-style callers where the async ctxmgr protocol is unavailable.
1424
+
1425
+ ## 📡 Stream Results
1426
+
1427
+ For direct Python usage, the simplest runtime path is:
1428
+
1429
+ ```python
1430
+ from fortify import stream_agent
1431
+
1432
+ async for event in stream_agent(agent, handler, "latest AI breakthroughs"):
1433
+ ...
1434
+ ```
1435
+
1436
+ `stream_agent(...)` yields normalized events for:
1437
+
1438
+ - assistant text deltas
1439
+ - tool lifecycle
1440
+ - final run completion