failproofai 1.0.3 → 1.0.4-beta.2

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 (251) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  3. package/.next/standalone/.next/prerender-manifest.json +5 -5
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/audit/invite/route_client-reference-manifest.js +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -4
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route_client-reference-manifest.js +1 -1
  28. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/status/route_client-reference-manifest.js +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/login-verify/route_client-reference-manifest.js +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/logout/route_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/auth/status/route_client-reference-manifest.js +1 -1
  38. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
  41. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +37 -4
  42. package/.next/standalone/.next/server/app/audit/page.js +2 -2
  43. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  45. package/.next/standalone/.next/server/app/index.html +1 -1
  46. package/.next/standalone/.next/server/app/index.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  48. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  56. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
  68. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
  72. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  73. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{[externals]__0-9ti7z._.js → [externals]__1lh7m5d._.js} +1 -1
  76. package/.next/standalone/.next/server/chunks/{[externals]__0u3wrvq._.js → [externals]__1rqkg_y._.js} +1 -1
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adacul._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/{_1-akg6w._.js → _08w6xzm._.js} +2 -2
  80. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +4 -4
  83. package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
  85. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
  86. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rgu2r3._.js +3 -0
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/_0-oij9d._.js +23 -0
  99. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/_0l2hi_d._.js +3 -0
  101. package/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +3 -0
  102. package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +47 -13
  103. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -69
  106. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_rerun-button_tsx_0blihgw._.js +3 -0
  107. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/node_modules_13n822a._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  115. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  116. package/.next/standalone/.next/server/pages/404.html +1 -1
  117. package/.next/standalone/.next/server/pages/500.html +1 -1
  118. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.json +54 -21
  120. package/.next/standalone/.next/static/chunks/010bv1w6j171t.js +1 -0
  121. package/.next/standalone/.next/static/chunks/04r6ch8uf_n8m.js +1 -0
  122. package/.next/standalone/.next/static/chunks/{29-iqnp9v_axt.css → 0o-hh5_turzlz.css} +1 -1
  123. package/.next/standalone/.next/static/chunks/{40nr6ehj4p3cw.css → 0qmsunv04x4na.css} +1 -1
  124. package/.next/standalone/.next/static/chunks/{0ollg-nnu3t3e.js → 0wz8yftk18ts2.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/0zebh1n9jkfbt.js +1 -0
  126. package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
  127. package/.next/standalone/.next/static/chunks/1pb1oztsbwcss.js +1 -0
  128. package/.next/standalone/.next/static/chunks/{1-_zkmnm-jwei.js → 2aquitk72k2op.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/2bi_1y0a_smt7.js +6 -0
  130. package/.next/standalone/.next/static/chunks/{42yfkhekx440s.js → 2ej3b8gk5ittu.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/{354-n7p7labpn.js → 2zafgs90r_leq.js} +1 -1
  132. package/.next/standalone/.next/static/chunks/{2xm5mj1d21b4n.js → 32spub4wqjem-.js} +1 -1
  133. package/.next/standalone/.next/static/chunks/{1a6u7l3giejch.js → 3m4upvybtrexd.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/{2k9f4tyv04809.css → 3uyhmm01g64k4.css} +1 -0
  135. package/.next/standalone/.next/static/chunks/{turbopack-2x-o099q64i8h.js → turbopack-3s3c-u_u_l0u9.js} +1 -1
  136. package/.next/standalone/.opencode/plugins/failproofai.mjs +51 -27
  137. package/.next/standalone/app/actions/get-leaks.ts +100 -0
  138. package/.next/standalone/app/audit/_components/audit-dashboard.tsx +112 -33
  139. package/.next/standalone/app/audit/_components/audit-poster.tsx +29 -10
  140. package/.next/standalone/app/audit/_components/come-back-better-section.tsx +6 -1
  141. package/.next/standalone/app/audit/_components/empty-state.tsx +4 -1
  142. package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +19 -5
  143. package/.next/standalone/app/audit/_components/leak-section.tsx +179 -0
  144. package/.next/standalone/app/audit/_components/share-templates.ts +110 -44
  145. package/.next/standalone/app/audit/audit-styles.css +114 -1
  146. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +26 -3
  147. package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +31 -8
  148. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +1 -0
  149. package/.next/standalone/fp-cloud-cli/pyproject.toml +9 -2
  150. package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +54 -0
  151. package/.next/standalone/fp-cloud-cli/uv.lock +9 -12
  152. package/.next/standalone/lib/auth/api-server-client.ts +28 -0
  153. package/.next/standalone/lib/claude-sessions.ts +101 -27
  154. package/.next/standalone/node_modules/@next/env/package.json +1 -1
  155. package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
  156. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
  157. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
  158. package/.next/standalone/node_modules/next/dist/compiled/next-server/app-route-turbo.runtime.prod.js +1 -1
  159. package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +5 -5
  160. package/.next/standalone/node_modules/next/dist/experimental/testmode/fetch.js +10 -2
  161. package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
  162. package/.next/standalone/node_modules/next/dist/lib/typescript/runTypeScriptCli.js +2 -2
  163. package/.next/standalone/node_modules/next/dist/lib/verify-typescript-setup.js +3 -1
  164. package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
  165. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
  166. package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
  167. package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
  168. package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/file-system-cache.js +17 -4
  169. package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
  170. package/.next/standalone/node_modules/next/dist/shared/lib/router/utils/escape-path-delimiters.js +1 -1
  171. package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
  172. package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
  173. package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
  174. package/.next/standalone/node_modules/next/package.json +11 -11
  175. package/.next/standalone/package.json +14 -13
  176. package/.next/standalone/sdk/python/CHANGELOG.md +155 -0
  177. package/.next/standalone/sdk/python/README.md +8 -0
  178. package/.next/standalone/sdk/python/examples/evaluator_worker.py +121 -0
  179. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
  180. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__init__.py +101 -0
  181. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__main__.py +49 -0
  182. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/_sandbox_runner.py +63 -0
  183. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/authoring.py +404 -0
  184. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/client.py +299 -0
  185. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/protocol.py +754 -0
  186. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/runtime.py +936 -0
  187. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/source.py +684 -0
  188. package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/README.md +28 -0
  189. package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/contract.json +252 -0
  190. package/.next/standalone/sdk/python/tests/test_evaluator_authoring.py +124 -0
  191. package/.next/standalone/sdk/python/tests/test_evaluator_client.py +253 -0
  192. package/.next/standalone/sdk/python/tests/test_evaluator_example.py +35 -0
  193. package/.next/standalone/sdk/python/tests/test_evaluator_http_e2e.py +636 -0
  194. package/.next/standalone/sdk/python/tests/test_evaluator_main.py +47 -0
  195. package/.next/standalone/sdk/python/tests/test_evaluator_protocol.py +246 -0
  196. package/.next/standalone/sdk/python/tests/test_evaluator_review_fixes.py +164 -0
  197. package/.next/standalone/sdk/python/tests/test_evaluator_runtime.py +1103 -0
  198. package/.next/standalone/sdk/python/tests/test_evaluator_source.py +430 -0
  199. package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +17 -0
  200. package/.next/standalone/sdk/python/uv.lock +29 -29
  201. package/.next/standalone/server.js +1 -1
  202. package/README.md +13 -58
  203. package/dist/cli.mjs +5368 -8756
  204. package/dist/worker.mjs +539 -312
  205. package/lib/auth/api-server-client.ts +28 -0
  206. package/lib/claude-sessions.ts +101 -27
  207. package/package.json +14 -13
  208. package/pi-extension/index.ts +42 -4
  209. package/scripts/changelog-open.py +115 -0
  210. package/src/audit/cli.ts +134 -0
  211. package/src/audit/desktop-notify.ts +420 -0
  212. package/src/audit/harm-report.ts +101 -0
  213. package/src/audit/index.ts +141 -13
  214. package/src/audit/leak-fingerprint.ts +200 -0
  215. package/src/audit/leak-notice.ts +161 -0
  216. package/src/audit/leak-record.ts +232 -0
  217. package/src/audit/leak-scan.ts +292 -0
  218. package/src/audit/leak-store.ts +217 -0
  219. package/src/audit/macos-notifier.ts +310 -0
  220. package/src/audit/redact-example.ts +266 -14
  221. package/src/audit/report-harm.ts +12 -1
  222. package/src/audit/report.ts +6 -3
  223. package/src/audit/schedule-cli.ts +37 -0
  224. package/src/audit/scoring.ts +49 -0
  225. package/src/audit/types.ts +41 -0
  226. package/src/hooks/builtin-policies.ts +67 -2
  227. package/src/hooks/configure-wizard.ts +20 -0
  228. package/src/hooks/fp-config.ts +71 -11
  229. package/src/hooks/fp-home.ts +27 -0
  230. package/src/hooks/handler.ts +59 -1
  231. package/src/hooks/integrations.ts +47 -13
  232. package/src/hooks/notice.ts +155 -0
  233. package/src/hooks/uninstall-cli.ts +15 -0
  234. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1b5jzxr._.js +0 -3
  235. package/.next/standalone/.next/server/chunks/ssr/_0j6gd_k._.js +0 -23
  236. package/.next/standalone/.next/server/chunks/ssr/_0v8eebd._.js +0 -3
  237. package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +0 -3
  238. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +0 -3
  239. package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_1ao30b1.js +0 -3
  240. package/.next/standalone/.next/static/chunks/03slu0c8_71y7.js +0 -1
  241. package/.next/standalone/.next/static/chunks/0nm11ysn0153h.js +0 -1
  242. package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +0 -1
  243. package/.next/standalone/.next/static/chunks/1r9676mkvihto.js +0 -1
  244. package/.next/standalone/.next/static/chunks/30pct5s734jua.js +0 -6
  245. package/.next/standalone/.next/static/chunks/3pibxnbhjza7n.js +0 -69
  246. package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +0 -1
  247. package/.next/standalone/.next/static/chunks/3zkg2s2vzxc3d.js +0 -1
  248. package/.next/standalone/.next/static/chunks/41b5ptaocvi_l.js +0 -1
  249. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_buildManifest.js +0 -0
  250. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_clientMiddlewareManifest.js +0 -0
  251. /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_ssgManifest.js +0 -0
@@ -0,0 +1,684 @@
1
+ """Restricted deterministic expression compiler for server-authored evaluations.
2
+
3
+ The managed worker never executes a module, statements, imports, or ambient
4
+ builtins from tenant-authored source. Definitions are single Python expressions
5
+ evaluated with a small constructor/helper surface and the immutable transcript
6
+ bound as ``session``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ import builtins
13
+ import hashlib
14
+ import os
15
+ import pickle
16
+ import re
17
+ import select
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ import threading
22
+ import time
23
+ from collections.abc import Callable
24
+ from typing import Any
25
+
26
+ try:
27
+ import resource as _resource
28
+ except ImportError: # pragma: no cover - non-POSIX
29
+ _resource = None # type: ignore[assignment]
30
+
31
+ from failproofai_sdk.evaluator.authoring import (
32
+ Assertion,
33
+ ConditionResult,
34
+ EvalResult,
35
+ Metric,
36
+ Score,
37
+ )
38
+
39
+ MAX_CONDITION_SOURCE_BYTES = 16 * 1024
40
+ MAX_EVALUATOR_SOURCE_BYTES = 128 * 1024
41
+
42
+ # Static defense-in-depth bounds applied at COMPILE time (see `_compile`). They
43
+ # reject the obvious authoring bombs early; they are NOT the primary defense —
44
+ # a runtime-computed size (`range(len(session.events) ** 40)`) slips past any
45
+ # static check, which is exactly why the fork sandbox below is the real bound.
46
+ MAX_AST_NODES = 5_000
47
+ MAX_POW_EXPONENT = 64
48
+
49
+ # Hard ceilings for ONE sandboxed evaluation, enforced by the kernel in a
50
+ # fork+exec'd subprocess (see `_run_sandboxed`). RLIMIT_CPU + the parent's
51
+ # wall-clock kill both bound compute bombs; RLIMIT_AS is the memory backstop for a
52
+ # giant-int / huge-allocation bomb.
53
+ DEFAULT_SANDBOX_TIMEOUT_SECONDS = 30
54
+ # The effective budget is CLAMPED to this ceiling regardless of the (server-set)
55
+ # per-definition timeout, so a large `timeout_seconds` can never remove the
56
+ # execution bound (SEC-001). Wall-clock and CPU are both capped here.
57
+ MAX_SANDBOX_TIMEOUT_SECONDS = 60
58
+ # Per-sandbox address-space cap. A managed eval works over a transcript (<=25 MiB)
59
+ # and returns a small result, so this is generous; it also rejects an allocation
60
+ # bomb (`[0] * 200000000` is ~1.6 GiB > this) before it returns a valid result.
61
+ SANDBOX_MEMORY_BYTES = 512 * 1024 * 1024 # 512 MiB
62
+ # ...but a per-process cap alone does not bound the HOST: a worker with
63
+ # max_concurrency=32 could run 32 sandboxes at once. Cap the number of concurrent
64
+ # sandbox processes so the AGGREGATE (MAX_CONCURRENT_SANDBOXES * SANDBOX_MEMORY_BYTES,
65
+ # ~2 GiB) is bounded independent of the worker's claim concurrency; extra evals
66
+ # queue on the semaphore rather than pile up memory.
67
+ MAX_CONCURRENT_SANDBOXES = 4
68
+ _SANDBOX_SLOTS = threading.Semaphore(MAX_CONCURRENT_SANDBOXES)
69
+ # The result crossing back is bounded on BOTH sides: the child refuses to serialize
70
+ # a result larger than this, and the parent stops reading (and kills the child)
71
+ # past it — so a permitted expression that builds a huge result
72
+ # (`EvalResult(metrics={str(x): 1 for x in range(100000)})`) cannot OOM the worker
73
+ # even though the child's RLIMIT_AS lets it construct one. A valid result (<=25
74
+ # items, bounded fields) is far under this.
75
+ SANDBOX_MAX_RESULT_BYTES = 1 * 1024 * 1024 # 1 MiB
76
+
77
+ # The sandbox child is scrubbed of the worker's environment.
78
+ #
79
+ # `subprocess.Popen` inherits `os.environ` by default, which on a worker means
80
+ # FAILPROOFAI_EVALUATOR_TOKEN — and on the FailproofAI-managed pod that token is
81
+ # the CROSS-TENANT credential the whole fleet authenticates with. The AST and
82
+ # empty-builtins restrictions already stop a managed expression from reading
83
+ # `os.environ`, so this is defence in depth rather than a fix for a live escape:
84
+ # it means a future gap in those restrictions cannot be escalated into credential
85
+ # theft. Only the variables the interpreter itself needs are forwarded — notably
86
+ # PYTHONPATH, without which the child cannot import the sandbox runner at all.
87
+ _SANDBOX_ENV_PASSTHROUGH = (
88
+ "PATH",
89
+ "PYTHONPATH",
90
+ "PYTHONHOME",
91
+ "PYTHONDONTWRITEBYTECODE",
92
+ "PYTHONUNBUFFERED",
93
+ "LANG",
94
+ "LC_ALL",
95
+ "LC_CTYPE",
96
+ "TMPDIR",
97
+ "SYSTEMROOT", # Windows: CPython fails to start without it
98
+ )
99
+
100
+
101
+ def _sandbox_env() -> dict[str, str]:
102
+ return {
103
+ name: os.environ[name]
104
+ for name in _SANDBOX_ENV_PASSTHROUGH
105
+ if os.environ.get(name)
106
+ }
107
+
108
+
109
+ def _clamp_budget(timeout_seconds: float | None) -> float:
110
+ """The wall-clock/CPU budget for one evaluation: a positive value no larger
111
+ than MAX_SANDBOX_TIMEOUT_SECONDS. Server-provided timeouts cannot exceed it."""
112
+ requested = float(timeout_seconds or DEFAULT_SANDBOX_TIMEOUT_SECONDS)
113
+ if requested <= 0:
114
+ requested = DEFAULT_SANDBOX_TIMEOUT_SECONDS
115
+ return min(requested, float(MAX_SANDBOX_TIMEOUT_SECONDS))
116
+
117
+
118
+ class EvaluationTimeout(Exception):
119
+ """A sandboxed evaluation exceeded its CPU/memory/wall-clock budget.
120
+
121
+ Distinct from an eval that *returned* an error: the computation was forcibly
122
+ terminated because it could not be allowed to keep running.
123
+ """
124
+
125
+
126
+ class EvaluationSandboxUnavailable(Exception):
127
+ """The killable-process sandbox could not be established.
128
+
129
+ Raised instead of running server-authored source unsandboxed — if the sandbox
130
+ subprocess cannot be started, or the transcript cannot be serialized into it,
131
+ there is no way to bound or terminate the evaluation, so we fail closed
132
+ (SEC-001).
133
+ """
134
+
135
+
136
+ def _install_limits(cpu_seconds: float, mem_bytes: int) -> None:
137
+ """Install hard CPU + address-space limits on the CURRENT process.
138
+
139
+ Called by the sandbox subprocess on itself, right before it evaluates.
140
+ """
141
+ if _resource is None: # pragma: no cover - non-POSIX
142
+ return
143
+ cpu = max(1, int(cpu_seconds))
144
+ _resource.setrlimit(_resource.RLIMIT_CPU, (cpu, cpu))
145
+ _resource.setrlimit(_resource.RLIMIT_AS, (mem_bytes, mem_bytes))
146
+
147
+
148
+ def _run_sandboxed(
149
+ kind: str,
150
+ source: str,
151
+ session: Any,
152
+ *,
153
+ wall_timeout: float,
154
+ cpu_seconds: float,
155
+ mem_bytes: int,
156
+ eval_key: str | None = None,
157
+ ) -> Any:
158
+ """Evaluate server-authored ``source`` against ``session`` in a fork+exec'd
159
+ subprocess that CANNOT outlive its budget or flood this process.
160
+
161
+ A FRESH ``python -m ..._sandbox_runner`` process — never a fork of this
162
+ multi-threaded worker (forking one deadlocks the child on a lock some other
163
+ thread holds) — reads its input from a temp file, installs hard RLIMIT_CPU +
164
+ RLIMIT_AS on itself, evaluates, and writes a bounded result to stdout. This
165
+ parent reads stdout up to ``SANDBOX_MAX_RESULT_BYTES`` and no further, killing
166
+ the child on timeout OR oversize — so neither compute (``sum(range(10**20))``)
167
+ nor an oversized result (``metrics={str(x):1 for x in range(100000)}``) can
168
+ exhaust the worker.
169
+ """
170
+ # SEC-001: without the stdlib ``resource`` module (e.g. Windows) the sandbox
171
+ # child cannot install RLIMIT_CPU / RLIMIT_AS on itself (``_install_limits``
172
+ # no-ops), so a permitted expression could allocate unbounded memory before
173
+ # the parent's wall-clock kill lands. Refuse BEFORE spawning any child rather
174
+ # than run server-authored source without the advertised limits.
175
+ if _resource is None: # pragma: no cover - non-POSIX
176
+ raise EvaluationSandboxUnavailable(
177
+ "kernel resource limits (RLIMIT_CPU/RLIMIT_AS) are unavailable on this "
178
+ "platform; managed evaluation cannot be bounded, refusing to run"
179
+ )
180
+ try:
181
+ session_wire = session.to_wire()
182
+ except AttributeError as error:
183
+ raise EvaluationSandboxUnavailable(
184
+ "sandboxed evaluation requires a serializable transcript"
185
+ ) from error
186
+ payload = pickle.dumps(
187
+ (kind, source, session_wire, cpu_seconds, mem_bytes, eval_key)
188
+ )
189
+ # Input via a temp file, not stdin: the transcript can be large (up to the
190
+ # transcript ceiling) and feeding a big stdin while bounding stdout invites a
191
+ # pipe deadlock. The child reads the file; we only read its stdout.
192
+ handle, path = tempfile.mkstemp(prefix="fpai-sandbox-", suffix=".pkl")
193
+ try:
194
+ with os.fdopen(handle, "wb") as tmp:
195
+ tmp.write(payload)
196
+ chunks: list[bytes] = []
197
+ total = 0
198
+ timed_out = False
199
+ too_large = False
200
+ # A slot is held for the whole subprocess lifetime so no more than
201
+ # MAX_CONCURRENT_SANDBOXES run at once — bounding aggregate memory across
202
+ # concurrent sandboxes. But acquiring it must COUNT AGAINST the wall-clock
203
+ # budget: the runtime runs this in a thread and `asyncio.wait_for` only
204
+ # cancels the awaiter, so a thread that blocked here UNBOUNDED past its
205
+ # deadline would still go on to launch a sandbox after its run was already
206
+ # reported timed out — 28 such threads could queue behind 4 long sandboxes
207
+ # and starve the worker (conditions have no runtime-level wait at all). One
208
+ # deadline therefore covers BOTH the slot wait and execution: we acquire the
209
+ # slot with the remaining budget and, on failure, time out WITHOUT spawning.
210
+ deadline = time.monotonic() + wall_timeout
211
+ acquire_timeout = deadline - time.monotonic()
212
+ if acquire_timeout <= 0 or not _SANDBOX_SLOTS.acquire(timeout=acquire_timeout):
213
+ raise EvaluationTimeout("evaluation timed out waiting for a sandbox slot")
214
+ try:
215
+ if deadline - time.monotonic() <= 0:
216
+ # Slot acquired exactly at the deadline: a child launched now could
217
+ # only be killed immediately, so do not spawn one at all.
218
+ raise EvaluationTimeout("evaluation timed out waiting for a sandbox slot")
219
+ try:
220
+ proc = subprocess.Popen( # noqa: S603 - fixed argv, no shell
221
+ [sys.executable, "-m", "failproofai_sdk.evaluator._sandbox_runner", path],
222
+ stdin=subprocess.DEVNULL,
223
+ stdout=subprocess.PIPE,
224
+ stderr=subprocess.DEVNULL,
225
+ env=_sandbox_env(),
226
+ )
227
+ except OSError as error:
228
+ raise EvaluationSandboxUnavailable(
229
+ f"could not start the evaluation sandbox: {error}"
230
+ ) from error
231
+ out_fd = proc.stdout.fileno()
232
+ try:
233
+ while True:
234
+ remaining = deadline - time.monotonic()
235
+ if remaining <= 0:
236
+ timed_out = True
237
+ break
238
+ ready, _, _ = select.select([out_fd], [], [], remaining)
239
+ if not ready:
240
+ timed_out = True
241
+ break
242
+ chunk = os.read(out_fd, 65536)
243
+ if not chunk:
244
+ break
245
+ total += len(chunk)
246
+ if total > SANDBOX_MAX_RESULT_BYTES:
247
+ too_large = True
248
+ break
249
+ chunks.append(chunk)
250
+ finally:
251
+ proc.stdout.close()
252
+ if proc.poll() is None:
253
+ proc.kill()
254
+ proc.wait()
255
+ finally:
256
+ _SANDBOX_SLOTS.release()
257
+ finally:
258
+ try:
259
+ os.unlink(path)
260
+ except OSError:
261
+ pass
262
+
263
+ if timed_out:
264
+ raise EvaluationTimeout("evaluation exceeded its wall-clock budget")
265
+ if too_large:
266
+ raise EvaluationTimeout("evaluation result exceeded the size limit")
267
+ data = b"".join(chunks)
268
+ if not data:
269
+ # Killed by RLIMIT_CPU/RLIMIT_AS (or otherwise died) before it could write.
270
+ raise EvaluationTimeout("evaluation was terminated before producing a result")
271
+ outcome = pickle.loads(data)
272
+ if outcome[0] == "ok":
273
+ return outcome[1]
274
+ # Preserve the child's original exception SEMANTICS: an eval's
275
+ # NameError/TypeError/ZeroDivisionError/... and the sandbox's own
276
+ # UnsafeEvaluatorSource must read the same as they did in-process. Reconstruct
277
+ # any builtin exception by name; anything else collapses to a generic error —
278
+ # still caught as a failed run upstream.
279
+ _, name, message = outcome
280
+ if name == UnsafeEvaluatorSource.__name__:
281
+ raise UnsafeEvaluatorSource(message)
282
+ builtin = getattr(builtins, name, None)
283
+ if isinstance(builtin, type) and issubclass(builtin, BaseException):
284
+ raise builtin(message)
285
+ raise RuntimeError(f"{name}: {message}")
286
+
287
+ _ALLOWED_NODES = (
288
+ ast.Expression,
289
+ ast.BoolOp,
290
+ ast.BinOp,
291
+ ast.UnaryOp,
292
+ ast.IfExp,
293
+ ast.Dict,
294
+ ast.Set,
295
+ ast.List,
296
+ ast.Tuple,
297
+ ast.ListComp,
298
+ ast.SetComp,
299
+ ast.DictComp,
300
+ # `ast.GeneratorExp` is intentionally NOT allowed: a bare generator object's
301
+ # default repr is `<generator object ... at 0x...>`, which leaks a live host
302
+ # heap address (an ASLR/memory-layout disclosure) the moment it is coerced to
303
+ # a string into any result field. List/set/dict comprehensions render as their
304
+ # data (`[...]`, `{...}`) and cover the same ground — wrap a generator in `[]`.
305
+ ast.comprehension,
306
+ ast.Compare,
307
+ ast.Call,
308
+ ast.FormattedValue,
309
+ ast.JoinedStr,
310
+ ast.Constant,
311
+ ast.Name,
312
+ ast.Load,
313
+ ast.Store,
314
+ ast.Attribute,
315
+ ast.Subscript,
316
+ ast.Slice,
317
+ ast.keyword,
318
+ ast.And,
319
+ ast.Or,
320
+ ast.Add,
321
+ ast.Sub,
322
+ ast.Mult,
323
+ ast.Div,
324
+ ast.FloorDiv,
325
+ ast.Mod,
326
+ ast.Pow,
327
+ ast.USub,
328
+ ast.UAdd,
329
+ ast.Not,
330
+ ast.Eq,
331
+ ast.NotEq,
332
+ ast.Lt,
333
+ ast.LtE,
334
+ ast.Gt,
335
+ ast.GtE,
336
+ ast.In,
337
+ ast.NotIn,
338
+ ast.Is,
339
+ ast.IsNot,
340
+ )
341
+
342
+ _SAFE_GLOBALS = {
343
+ "__builtins__": {},
344
+ "Assertion": Assertion,
345
+ "ConditionResult": ConditionResult,
346
+ "EvalResult": EvalResult,
347
+ "Metric": Metric,
348
+ "Score": Score,
349
+ "abs": abs,
350
+ "all": all,
351
+ "any": any,
352
+ "bool": bool,
353
+ "dict": dict,
354
+ # `enumerate` is intentionally excluded: an enumerate object's default repr is
355
+ # `<enumerate object at 0x...>`, leaking a live host heap address into any
356
+ # result field. Index-aware iteration can use `range(len(...))` instead.
357
+ "float": float,
358
+ "int": int,
359
+ "len": len,
360
+ "list": list,
361
+ "max": max,
362
+ "min": min,
363
+ "range": range,
364
+ "round": round,
365
+ "set": set,
366
+ "sorted": sorted,
367
+ "str": str,
368
+ "sum": sum,
369
+ "tuple": tuple,
370
+ }
371
+
372
+
373
+ # Attribute access is DEFAULT-DENY. A denylist is unwinnable here: dunder access
374
+ # is only one door. `str.format`/`format_map` traverse a format string's fields
375
+ # at the C level; `(x for x in [1]).gi_frame.f_globals` reaches the eval globals
376
+ # through generator/frame introspection; `str.mro()[-1]` reaches the `object`
377
+ # type — and NONE of `format`, `gi_frame`, `f_globals`, `co_names`, `mro`, ...
378
+ # start with an underscore, so the dunder guard never sees them. Rather than
379
+ # chase each introspection family, we allow ONLY the attribute names a real
380
+ # session evaluation needs: the transcript/event data surface plus a fixed set
381
+ # of pure string/collection data methods. Anything else — every current and
382
+ # future introspection attribute — is rejected. `format`/`format_map` are simply
383
+ # absent from this set, so the C-level format escape is closed too.
384
+ # Data attributes on the transcript surface — safe to READ as a value: each is a
385
+ # field of a frozen dataclass (SessionTranscript / TranscriptEvent, whose reprs are
386
+ # field-based and pointer-free) or a JSON scalar/container from an event payload.
387
+ _DATA_ATTRS = frozenset(
388
+ {
389
+ # SessionTranscript + TranscriptEvent data surface (see protocol.py).
390
+ "events",
391
+ "event_count",
392
+ "event_type",
393
+ "payload",
394
+ "id",
395
+ "ts",
396
+ "agent_id",
397
+ "environment",
398
+ "session_id",
399
+ "session_revision_id",
400
+ "assignment_id",
401
+ "started_at",
402
+ "ended_at",
403
+ "schema_version",
404
+ }
405
+ )
406
+
407
+ # Method attributes — pure data methods that must be CALLED, never referenced as a
408
+ # bare value. A bound method's repr is `<... at 0x...>`, a live heap address; a bare
409
+ # reference (`payload.get` uncalled) is only ever useful for smuggling that address
410
+ # into a result field via `str()`, an f-string, or `%`-formatting — none of which a
411
+ # real evaluation needs. `_compile` requires each of these names to appear at a call
412
+ # site, which closes every text-coercion leak at its source: no reachable value can
413
+ # then carry a pointer repr, so the output-boundary scan is only defense in depth.
414
+ _METHOD_ATTRS = frozenset(
415
+ {
416
+ # SessionTranscript methods.
417
+ "events_of_type",
418
+ "count",
419
+ # dict data methods.
420
+ "get",
421
+ "keys",
422
+ "values",
423
+ "items",
424
+ # str / bytes pure data methods.
425
+ "lower",
426
+ "upper",
427
+ "strip",
428
+ "lstrip",
429
+ "rstrip",
430
+ "split",
431
+ "rsplit",
432
+ "splitlines",
433
+ "startswith",
434
+ "endswith",
435
+ "replace",
436
+ "find",
437
+ "rfind",
438
+ "index",
439
+ "join",
440
+ "title",
441
+ "capitalize",
442
+ "casefold",
443
+ "swapcase",
444
+ "isdigit",
445
+ "isalpha",
446
+ "isalnum",
447
+ "isspace",
448
+ "isnumeric",
449
+ "isdecimal",
450
+ "islower",
451
+ "isupper",
452
+ "istitle",
453
+ "zfill",
454
+ "ljust",
455
+ "rjust",
456
+ "center",
457
+ "partition",
458
+ "rpartition",
459
+ "removeprefix",
460
+ "removesuffix",
461
+ "encode",
462
+ "decode",
463
+ "hex",
464
+ # set data methods.
465
+ "union",
466
+ "intersection",
467
+ "difference",
468
+ "symmetric_difference",
469
+ "issubset",
470
+ "issuperset",
471
+ "isdisjoint",
472
+ }
473
+ )
474
+
475
+ # The walk rejects any attribute outside this union, and additionally requires every
476
+ # name in `_METHOD_ATTRS` to appear only as the function of a call.
477
+ _ALLOWED_ATTRS = _DATA_ATTRS | _METHOD_ATTRS
478
+
479
+
480
+ def _fresh_globals() -> dict[str, Any]:
481
+ """A throwaway globals mapping for one eval call.
482
+
483
+ Every evaluation gets its own copy — with a fresh empty ``__builtins__`` —
484
+ so that even if a future reach exposes the eval's globals (e.g. through a
485
+ frame object), a mutation cannot persist into another evaluation and poison
486
+ a shared, process-wide namespace.
487
+ """
488
+ return {**_SAFE_GLOBALS, "__builtins__": {}}
489
+
490
+
491
+ class UnsafeEvaluatorSource(ValueError):
492
+ """Raised before any disallowed server-authored source can execute."""
493
+
494
+
495
+ def source_checksum(condition_source: str | None, evaluator_source: str) -> str:
496
+ payload = (condition_source or "").encode("utf-8") + b"\0" + evaluator_source.encode(
497
+ "utf-8"
498
+ )
499
+ return "sha256:" + hashlib.sha256(payload).hexdigest()
500
+
501
+
502
+ def _compile(source: str, *, field_name: str, maximum: int) -> Any:
503
+ if not isinstance(source, str) or not source.strip():
504
+ raise UnsafeEvaluatorSource(f"{field_name} must not be empty")
505
+ if len(source.encode("utf-8")) > maximum:
506
+ raise UnsafeEvaluatorSource(f"{field_name} exceeds {maximum} bytes")
507
+ try:
508
+ tree = ast.parse(source, mode="eval")
509
+ except SyntaxError as error:
510
+ raise UnsafeEvaluatorSource(f"{field_name} must be one expression") from error
511
+ # An Attribute that is the function of a Call is a method invocation; any other
512
+ # Attribute naming a method (`_METHOD_ATTRS`) is a bare bound-method reference,
513
+ # whose only use is leaking the method's `<... at 0xADDR>` repr into a result.
514
+ called_method_nodes = {
515
+ node.func
516
+ for node in ast.walk(tree)
517
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
518
+ }
519
+ node_count = 0
520
+ for node in ast.walk(tree):
521
+ node_count += 1
522
+ if node_count > MAX_AST_NODES:
523
+ raise UnsafeEvaluatorSource(
524
+ f"{field_name} is too large ({MAX_AST_NODES}-node ceiling)"
525
+ )
526
+ if not isinstance(node, _ALLOWED_NODES):
527
+ raise UnsafeEvaluatorSource(
528
+ f"{field_name} contains disallowed syntax: {type(node).__name__}"
529
+ )
530
+ # Defense in depth: a literal `10 ** 20` (or worse, `2 ** (10**8)`) builds a
531
+ # giant int — a memory bomb — at compile-time-visible size. Require Pow's
532
+ # exponent to be a small non-negative integer constant. Runtime-sized bombs
533
+ # still exist and are caught by the fork sandbox, not here.
534
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Pow):
535
+ exponent = node.right
536
+ if not (
537
+ isinstance(exponent, ast.Constant)
538
+ and isinstance(exponent.value, int)
539
+ and not isinstance(exponent.value, bool)
540
+ and 0 <= exponent.value <= MAX_POW_EXPONENT
541
+ ):
542
+ raise UnsafeEvaluatorSource(
543
+ f"{field_name} exponent must be an integer constant "
544
+ f"in 0..{MAX_POW_EXPONENT}"
545
+ )
546
+ if isinstance(node, ast.Attribute):
547
+ if node.attr.startswith("_"):
548
+ raise UnsafeEvaluatorSource(
549
+ f"{field_name} may not access private or dunder attributes"
550
+ )
551
+ if node.attr not in _ALLOWED_ATTRS:
552
+ raise UnsafeEvaluatorSource(
553
+ f"{field_name} may not access attribute '{node.attr}'"
554
+ )
555
+ if node.attr in _METHOD_ATTRS and node not in called_method_nodes:
556
+ raise UnsafeEvaluatorSource(
557
+ f"{field_name} may reference method '{node.attr}' only to call it; "
558
+ "a bare bound method leaks a heap address when stringified"
559
+ )
560
+ if isinstance(node, ast.Name) and node.id.startswith("_"):
561
+ raise UnsafeEvaluatorSource(f"{field_name} may not access private names")
562
+ return compile(tree, f"<{field_name}>", "eval", dont_inherit=True, optimize=2)
563
+
564
+
565
+ # CPython's default object repr — `<... at 0x7f...>` — embeds a live heap address
566
+ # (an ASLR/memory-layout disclosure). The PRIMARY defense is at compile time: a bound
567
+ # method (the only reachable object with such a repr — the result and transcript types
568
+ # are all frozen, pointer-free dataclasses) can no longer be referenced as a value
569
+ # (`_METHOD_ATTRS` must be called), so no reachable value carries a pointer repr to
570
+ # begin with. This output-boundary scan is DEFENSE IN DEPTH. It matches the "... at
571
+ # 0xADDR" tail every default object repr shares — which survives even a reshaped
572
+ # wrapper such as `str(x).replace("<","")`, since stripping the leading `<` leaves
573
+ # the " at 0x..." tail intact. A bare `0x`+hex run is deliberately NOT matched: it
574
+ # false-rejects legitimate result text (a hex colour like `0xFFFFFF`, a git-style
575
+ # digest, or an address the agent itself logged and the eval quotes), marking a
576
+ # correct evaluation as failed for embedding an ordinary hex literal.
577
+ _OBJECT_REPR = re.compile(r" at 0x[0-9a-fA-F]+")
578
+
579
+
580
+ def _forbid_object_reprs(field_name: str, value: Any) -> Any:
581
+ if _OBJECT_REPR.search(repr(value)):
582
+ raise UnsafeEvaluatorSource(
583
+ f"{field_name} result may not embed a runtime object repr"
584
+ )
585
+ return value
586
+
587
+
588
+ def _raw_eval(source: str, kind: str) -> Callable[[Any], Any]:
589
+ """Compile server-authored source and return a function that evaluates it and
590
+ validates the result.
591
+
592
+ Runs INSIDE the sandbox subprocess (see `_sandbox_runner`) — there is no
593
+ isolation here. `compile_condition`/`compile_evaluator` have already validated
594
+ the AST in the parent; this recompiles as defense in depth so a subprocess
595
+ can never eval source the parent has not vetted.
596
+ """
597
+ if kind == "condition":
598
+ code = _compile(
599
+ source, field_name="condition_source", maximum=MAX_CONDITION_SOURCE_BYTES
600
+ )
601
+
602
+ def run(session: Any) -> Any:
603
+ # `session` goes in the (fresh, per-call) GLOBALS, not locals: on
604
+ # CPython 3.10 a list/set/dict comprehension resolves a free name like
605
+ # `session` from globals, so an allowed source such as
606
+ # `all([session.event_count > 0 for _ in range(1)])` raises NameError
607
+ # if `session` is only a local. Globals stay fresh per call for
608
+ # isolation (see `_fresh_globals`); locals are empty.
609
+ value = eval(code, {**_fresh_globals(), "session": session}, {}) # noqa: S307
610
+ if not isinstance(value, (bool, ConditionResult)):
611
+ raise TypeError("condition_source must return bool or ConditionResult")
612
+ return _forbid_object_reprs("condition_source", value)
613
+
614
+ else:
615
+ code = _compile(
616
+ source, field_name="evaluator_source", maximum=MAX_EVALUATOR_SOURCE_BYTES
617
+ )
618
+
619
+ def run(session: Any) -> Any:
620
+ # `session` goes in the (fresh, per-call) GLOBALS, not locals: on
621
+ # CPython 3.10 a list/set/dict comprehension resolves a free name like
622
+ # `session` from globals, so an allowed source such as
623
+ # `all([session.event_count > 0 for _ in range(1)])` raises NameError
624
+ # if `session` is only a local. Globals stay fresh per call for
625
+ # isolation (see `_fresh_globals`); locals are empty.
626
+ value = eval(code, {**_fresh_globals(), "session": session}, {}) # noqa: S307
627
+ if not isinstance(value, EvalResult):
628
+ raise TypeError("evaluator_source must return EvalResult")
629
+ return _forbid_object_reprs("evaluator_source", value)
630
+
631
+ return run
632
+
633
+
634
+ def compile_condition(
635
+ source: str,
636
+ *,
637
+ timeout_seconds: float | None = DEFAULT_SANDBOX_TIMEOUT_SECONDS,
638
+ ) -> Callable[[Any], bool | ConditionResult]:
639
+ # Validate the AST in THIS (parent) process so unsafe/malformed source is
640
+ # rejected up front, before any subprocess is spawned.
641
+ _compile(source, field_name="condition_source", maximum=MAX_CONDITION_SOURCE_BYTES)
642
+ budget = _clamp_budget(timeout_seconds)
643
+
644
+ def condition(session: Any) -> bool | ConditionResult:
645
+ # Managed conditions are sandboxed like evaluators — `sum(range(10**10)) > 0`
646
+ # in a condition would otherwise block the worker with NO timeout at all.
647
+ return _run_sandboxed(
648
+ "condition",
649
+ source,
650
+ session,
651
+ wall_timeout=budget,
652
+ cpu_seconds=budget,
653
+ mem_bytes=SANDBOX_MEMORY_BYTES,
654
+ )
655
+
656
+ return condition
657
+
658
+
659
+ def compile_evaluator(
660
+ source: str,
661
+ *,
662
+ timeout_seconds: float | None = DEFAULT_SANDBOX_TIMEOUT_SECONDS,
663
+ eval_key: str | None = None,
664
+ ) -> Callable[[Any], EvalResult]:
665
+ _compile(source, field_name="evaluator_source", maximum=MAX_EVALUATOR_SOURCE_BYTES)
666
+ budget = _clamp_budget(timeout_seconds)
667
+
668
+ def evaluate(session: Any) -> EvalResult:
669
+ # The kernel-enforced boundary: this runs in a fork+exec'd subprocess with
670
+ # hard CPU/memory/wall-clock limits and a bounded result, killed if it
671
+ # exceeds them, so a server-authored compute or result bomb cannot exhaust
672
+ # the worker (SEC-001). `eval_key` lets the child validate the result's
673
+ # 25-item limit before it crosses back.
674
+ return _run_sandboxed(
675
+ "evaluator",
676
+ source,
677
+ session,
678
+ wall_timeout=budget,
679
+ cpu_seconds=budget,
680
+ mem_bytes=SANDBOX_MEMORY_BYTES,
681
+ eval_key=eval_key,
682
+ )
683
+
684
+ return evaluate