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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +5 -5
- package/.next/standalone/.next/prerender-manifest.json +5 -5
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/invite/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +4 -4
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +37 -4
- package/.next/standalone/.next/server/app/audit/page.js +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__0-9ti7z._.js → [externals]__1lh7m5d._.js} +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__0u3wrvq._.js → [externals]__1rqkg_y._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1adacul._.js +1 -1
- package/.next/standalone/.next/server/chunks/{_1-akg6w._.js → _08w6xzm._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +4 -4
- package/.next/standalone/.next/server/chunks/lib_telemetry_ts_0dctyyw._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rgu2r3._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_0-oij9d._.js +23 -0
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0l2hi_d._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0oq1dh6._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1es2j7i._.js +47 -13
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -69
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_rerun-button_tsx_0blihgw._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_13n822a._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-config_ts_04t589g._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +54 -21
- package/.next/standalone/.next/static/chunks/010bv1w6j171t.js +1 -0
- package/.next/standalone/.next/static/chunks/04r6ch8uf_n8m.js +1 -0
- package/.next/standalone/.next/static/chunks/{29-iqnp9v_axt.css → 0o-hh5_turzlz.css} +1 -1
- package/.next/standalone/.next/static/chunks/{40nr6ehj4p3cw.css → 0qmsunv04x4na.css} +1 -1
- package/.next/standalone/.next/static/chunks/{0ollg-nnu3t3e.js → 0wz8yftk18ts2.js} +1 -1
- package/.next/standalone/.next/static/chunks/0zebh1n9jkfbt.js +1 -0
- package/.next/standalone/.next/static/chunks/1eyl7fzkmnkf_.js +1 -0
- package/.next/standalone/.next/static/chunks/1pb1oztsbwcss.js +1 -0
- package/.next/standalone/.next/static/chunks/{1-_zkmnm-jwei.js → 2aquitk72k2op.js} +1 -1
- package/.next/standalone/.next/static/chunks/2bi_1y0a_smt7.js +6 -0
- package/.next/standalone/.next/static/chunks/{42yfkhekx440s.js → 2ej3b8gk5ittu.js} +1 -1
- package/.next/standalone/.next/static/chunks/{354-n7p7labpn.js → 2zafgs90r_leq.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2xm5mj1d21b4n.js → 32spub4wqjem-.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1a6u7l3giejch.js → 3m4upvybtrexd.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2k9f4tyv04809.css → 3uyhmm01g64k4.css} +1 -0
- package/.next/standalone/.next/static/chunks/{turbopack-2x-o099q64i8h.js → turbopack-3s3c-u_u_l0u9.js} +1 -1
- package/.next/standalone/.opencode/plugins/failproofai.mjs +51 -27
- package/.next/standalone/app/actions/get-leaks.ts +100 -0
- package/.next/standalone/app/audit/_components/audit-dashboard.tsx +112 -33
- package/.next/standalone/app/audit/_components/audit-poster.tsx +29 -10
- package/.next/standalone/app/audit/_components/come-back-better-section.tsx +6 -1
- package/.next/standalone/app/audit/_components/empty-state.tsx +4 -1
- package/.next/standalone/app/audit/_components/how-to-improve-section.tsx +19 -5
- package/.next/standalone/app/audit/_components/leak-section.tsx +179 -0
- package/.next/standalone/app/audit/_components/share-templates.ts +110 -44
- package/.next/standalone/app/audit/audit-styles.css +114 -1
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +26 -3
- package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +31 -8
- package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +1 -0
- package/.next/standalone/fp-cloud-cli/pyproject.toml +9 -2
- package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +54 -0
- package/.next/standalone/fp-cloud-cli/uv.lock +9 -12
- package/.next/standalone/lib/auth/api-server-client.ts +28 -0
- package/.next/standalone/lib/claude-sessions.ts +101 -27
- package/.next/standalone/node_modules/@next/env/package.json +1 -1
- package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo-experimental.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/app-route-turbo.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +5 -5
- package/.next/standalone/node_modules/next/dist/experimental/testmode/fetch.js +10 -2
- package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
- package/.next/standalone/node_modules/next/dist/lib/typescript/runTypeScriptCli.js +2 -2
- package/.next/standalone/node_modules/next/dist/lib/verify-typescript-setup.js +3 -1
- package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/incremental-cache/file-system-cache.js +17 -4
- package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
- package/.next/standalone/node_modules/next/dist/shared/lib/router/utils/escape-path-delimiters.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
- package/.next/standalone/node_modules/next/package.json +11 -11
- package/.next/standalone/package.json +14 -13
- package/.next/standalone/sdk/python/CHANGELOG.md +155 -0
- package/.next/standalone/sdk/python/README.md +8 -0
- package/.next/standalone/sdk/python/examples/evaluator_worker.py +121 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -1
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__init__.py +101 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__main__.py +49 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/_sandbox_runner.py +63 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/authoring.py +404 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/client.py +299 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/protocol.py +754 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/runtime.py +936 -0
- package/.next/standalone/sdk/python/failproofai_sdk/evaluator/source.py +684 -0
- package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/README.md +28 -0
- package/.next/standalone/sdk/python/tests/fixtures/evaluator_v2/contract.json +252 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_authoring.py +124 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_client.py +253 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_example.py +35 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_http_e2e.py +636 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_main.py +47 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_protocol.py +246 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_review_fixes.py +164 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_runtime.py +1103 -0
- package/.next/standalone/sdk/python/tests/test_evaluator_source.py +430 -0
- package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +17 -0
- package/.next/standalone/sdk/python/uv.lock +29 -29
- package/.next/standalone/server.js +1 -1
- package/README.md +13 -58
- package/dist/cli.mjs +5368 -8756
- package/dist/worker.mjs +539 -312
- package/lib/auth/api-server-client.ts +28 -0
- package/lib/claude-sessions.ts +101 -27
- package/package.json +14 -13
- package/pi-extension/index.ts +42 -4
- package/scripts/changelog-open.py +115 -0
- package/src/audit/cli.ts +134 -0
- package/src/audit/desktop-notify.ts +420 -0
- package/src/audit/harm-report.ts +101 -0
- package/src/audit/index.ts +141 -13
- package/src/audit/leak-fingerprint.ts +200 -0
- package/src/audit/leak-notice.ts +161 -0
- package/src/audit/leak-record.ts +232 -0
- package/src/audit/leak-scan.ts +292 -0
- package/src/audit/leak-store.ts +217 -0
- package/src/audit/macos-notifier.ts +310 -0
- package/src/audit/redact-example.ts +266 -14
- package/src/audit/report-harm.ts +12 -1
- package/src/audit/report.ts +6 -3
- package/src/audit/schedule-cli.ts +37 -0
- package/src/audit/scoring.ts +49 -0
- package/src/audit/types.ts +41 -0
- package/src/hooks/builtin-policies.ts +67 -2
- package/src/hooks/configure-wizard.ts +20 -0
- package/src/hooks/fp-config.ts +71 -11
- package/src/hooks/fp-home.ts +27 -0
- package/src/hooks/handler.ts +59 -1
- package/src/hooks/integrations.ts +47 -13
- package/src/hooks/notice.ts +155 -0
- package/src/hooks/uninstall-cli.ts +15 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1b5jzxr._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0j6gd_k._.js +0 -23
- package/.next/standalone/.next/server/chunks/ssr/_0v8eebd._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_1ylae7o._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/node_modules_html-to-image_es_index_1ao30b1.js +0 -3
- package/.next/standalone/.next/static/chunks/03slu0c8_71y7.js +0 -1
- package/.next/standalone/.next/static/chunks/0nm11ysn0153h.js +0 -1
- package/.next/standalone/.next/static/chunks/0u64yey446k4f.css +0 -1
- package/.next/standalone/.next/static/chunks/1r9676mkvihto.js +0 -1
- package/.next/standalone/.next/static/chunks/30pct5s734jua.js +0 -6
- package/.next/standalone/.next/static/chunks/3pibxnbhjza7n.js +0 -69
- package/.next/standalone/.next/static/chunks/3qfoeqhy5l10x.js +0 -1
- package/.next/standalone/.next/static/chunks/3zkg2s2vzxc3d.js +0 -1
- package/.next/standalone/.next/static/chunks/41b5ptaocvi_l.js +0 -1
- /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{JBYgkXkA1PS48m9P_hKNw → aKNv4Kmv98Xpwns0Xa-C-}/_clientMiddlewareManifest.js +0 -0
- /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
|