evalrx 0.1.2__py3-none-any.whl
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.
- evalrx/__init__.py +139 -0
- evalrx/agent_assets/__init__.py +2 -0
- evalrx/agent_assets/skills/README.md +28 -0
- evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
- evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
- evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
- evalrx/agent_assets/skills/nature-figure/README.md +412 -0
- evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
- evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
- evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
- evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
- evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
- evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
- evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
- evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
- evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
- evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
- evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
- evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
- evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
- evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
- evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
- evalrx/agent_assets/skills.py +27 -0
- evalrx/agent_runtime/__init__.py +78 -0
- evalrx/agent_runtime/_docker_runner.py +89 -0
- evalrx/agent_runtime/cli_runtime.py +103 -0
- evalrx/agent_runtime/cli_transcript.py +138 -0
- evalrx/agent_runtime/cli_types.py +68 -0
- evalrx/agent_runtime/codegen/__init__.py +5 -0
- evalrx/agent_runtime/codegen/runner.py +94 -0
- evalrx/agent_runtime/experiment_harness.py +117 -0
- evalrx/agent_runtime/factory.py +102 -0
- evalrx/agent_runtime/json_shape.py +44 -0
- evalrx/agent_runtime/judges/__init__.py +28 -0
- evalrx/agent_runtime/judges/agy.py +179 -0
- evalrx/agent_runtime/judges/autodetect.py +135 -0
- evalrx/agent_runtime/judges/claude.py +159 -0
- evalrx/agent_runtime/judges/codex.py +120 -0
- evalrx/agent_runtime/providers/__init__.py +21 -0
- evalrx/agent_runtime/providers/antigravity.py +31 -0
- evalrx/agent_runtime/providers/base.py +145 -0
- evalrx/agent_runtime/providers/claude_code.py +49 -0
- evalrx/agent_runtime/providers/codex.py +37 -0
- evalrx/agent_runtime/providers/gemini_cli.py +26 -0
- evalrx/agent_runtime/providers/kimi_cli.py +27 -0
- evalrx/agent_runtime/providers/opencode.py +27 -0
- evalrx/agent_runtime/providers/registry.py +58 -0
- evalrx/agent_runtime/sandbox.py +517 -0
- evalrx/agent_runtime/skill_audit.py +143 -0
- evalrx/agent_runtime/skills/__init__.py +19 -0
- evalrx/agent_runtime/skills/installer.py +68 -0
- evalrx/agent_runtime/skills/prompt_policy.py +86 -0
- evalrx/agent_runtime/skills/resolver.py +19 -0
- evalrx/analysis/__init__.py +132 -0
- evalrx/analysis/adjudicate.py +154 -0
- evalrx/analysis/analysis_module.py +361 -0
- evalrx/analysis/api.py +171 -0
- evalrx/analysis/case_studio.py +651 -0
- evalrx/analysis/cli.py +114 -0
- evalrx/analysis/dashboard.py +350 -0
- evalrx/analysis/eval_case_matrix.py +118 -0
- evalrx/analysis/eval_viz_theme.py +833 -0
- evalrx/analysis/explore_run.py +333 -0
- evalrx/analysis/explorer.py +1276 -0
- evalrx/analysis/failure_modes.py +607 -0
- evalrx/analysis/fused_pipeline.py +489 -0
- evalrx/analysis/holdout.py +300 -0
- evalrx/analysis/hypothesis_agent.py +230 -0
- evalrx/analysis/narration.py +177 -0
- evalrx/analysis/operationalize.py +442 -0
- evalrx/analysis/plain_language.py +42 -0
- evalrx/analysis/planner.py +283 -0
- evalrx/analysis/probe_search.py +203 -0
- evalrx/analysis/profile.py +268 -0
- evalrx/analysis/prompts/__init__.py +0 -0
- evalrx/analysis/prompts/explorer.py +417 -0
- evalrx/analysis/prompts/failure_modes.py +33 -0
- evalrx/analysis/prompts/holdout.py +27 -0
- evalrx/analysis/prompts/hypothesis_agent.py +78 -0
- evalrx/analysis/prompts/run_codebase.py +47 -0
- evalrx/analysis/prompts/stats_agent.py +72 -0
- evalrx/analysis/prompts/stats_tool_generator.py +43 -0
- evalrx/analysis/result_marker.py +47 -0
- evalrx/analysis/run_codebase.py +242 -0
- evalrx/analysis/run_view.py +205 -0
- evalrx/analysis/stage_views.py +93 -0
- evalrx/analysis/stats_agent.py +944 -0
- evalrx/analysis/stats_tool_agent.py +261 -0
- evalrx/analysis/stats_tool_generator.py +415 -0
- evalrx/analysis/stats_tools.py +1153 -0
- evalrx/analysis/trajectory_records.py +193 -0
- evalrx/analysis/workbench.py +431 -0
- evalrx/analyzers/__init__.py +42 -0
- evalrx/analyzers/agent/__init__.py +25 -0
- evalrx/analyzers/agent/counterfactual.py +84 -0
- evalrx/analyzers/agent/first_error_judge.py +96 -0
- evalrx/analyzers/agent/ignored_obs.py +81 -0
- evalrx/analyzers/agent/loop_detect.py +79 -0
- evalrx/analyzers/agent/reliability.py +165 -0
- evalrx/analyzers/agent/tool_shap.py +225 -0
- evalrx/analyzers/agent/trajectory_rubric.py +168 -0
- evalrx/analyzers/attention/__init__.py +19 -0
- evalrx/analyzers/attention/relative_attn.py +610 -0
- evalrx/analyzers/attention/rollout.py +73 -0
- evalrx/analyzers/attention/sink.py +56 -0
- evalrx/analyzers/attention/summary.py +190 -0
- evalrx/analyzers/attribution/__init__.py +6 -0
- evalrx/analyzers/attribution/generic_attn.py +31 -0
- evalrx/analyzers/attribution/gradcam.py +30 -0
- evalrx/analyzers/base.py +12 -0
- evalrx/analyzers/geometry/__init__.py +6 -0
- evalrx/analyzers/geometry/cka.py +70 -0
- evalrx/analyzers/geometry/linear_probe.py +157 -0
- evalrx/analyzers/hallucination/__init__.py +9 -0
- evalrx/analyzers/hallucination/chair.py +78 -0
- evalrx/analyzers/hallucination/opera.py +29 -0
- evalrx/analyzers/hallucination/pope.py +119 -0
- evalrx/analyzers/hallucination/selfcheck.py +155 -0
- evalrx/analyzers/hallucination/vcd.py +29 -0
- evalrx/analyzers/lens/__init__.py +7 -0
- evalrx/analyzers/lens/layer_contrast.py +133 -0
- evalrx/analyzers/lens/logit_lens.py +138 -0
- evalrx/analyzers/lens/tuned_lens.py +30 -0
- evalrx/analyzers/patching/__init__.py +5 -0
- evalrx/analyzers/patching/causal_trace.py +30 -0
- evalrx/analyzers/perturbation/__init__.py +23 -0
- evalrx/analyzers/perturbation/_shapley.py +54 -0
- evalrx/analyzers/perturbation/context_shap.py +174 -0
- evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
- evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
- evalrx/analyzers/perturbation/mm_shap.py +146 -0
- evalrx/analyzers/perturbation/modality_ablation.py +196 -0
- evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
- evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
- evalrx/analyzers/perturbation/rise.py +94 -0
- evalrx/analyzers/perturbation/vl_shap.py +102 -0
- evalrx/analyzers/reasoning/__init__.py +33 -0
- evalrx/analyzers/reasoning/_text.py +328 -0
- evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
- evalrx/analyzers/reasoning/arith_audit.py +226 -0
- evalrx/analyzers/reasoning/contamination.py +214 -0
- evalrx/analyzers/reasoning/knowledge_split.py +253 -0
- evalrx/analyzers/reasoning/self_repair.py +246 -0
- evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
- evalrx/analyzers/reasoning/termination_audit.py +258 -0
- evalrx/analyzers/uncertainty/__init__.py +18 -0
- evalrx/analyzers/uncertainty/calibration.py +174 -0
- evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
- evalrx/analyzers/uncertainty/entropy.py +90 -0
- evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
- evalrx/analyzers/uncertainty/self_consistency.py +204 -0
- evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
- evalrx/cli.py +411 -0
- evalrx/config.py +77 -0
- evalrx/contract/__init__.py +179 -0
- evalrx/contract/common.py +452 -0
- evalrx/contract/emit.py +948 -0
- evalrx/contract/export.py +237 -0
- evalrx/contract/m1.py +325 -0
- evalrx/contract/m2.py +317 -0
- evalrx/contract/m3.py +165 -0
- evalrx/contract/m4.py +130 -0
- evalrx/contract/m5.py +292 -0
- evalrx/contract/methodology.py +76 -0
- evalrx/contract/pre_m1.py +58 -0
- evalrx/contract/typescript.py +140 -0
- evalrx/core/__init__.py +85 -0
- evalrx/core/analyzer.py +174 -0
- evalrx/core/capability.py +54 -0
- evalrx/core/case.py +443 -0
- evalrx/core/experiment.py +106 -0
- evalrx/core/model.py +198 -0
- evalrx/core/pipeline.py +42 -0
- evalrx/core/registry.py +142 -0
- evalrx/core/result.py +64 -0
- evalrx/core/spec.py +173 -0
- evalrx/core/tokentype.py +165 -0
- evalrx/core/tool.py +92 -0
- evalrx/datasets/__init__.py +41 -0
- evalrx/datasets/base.py +68 -0
- evalrx/datasets/gui_os.py +52 -0
- evalrx/datasets/llm_qa.py +57 -0
- evalrx/datasets/pure_qa.py +12 -0
- evalrx/datasets/vlm_qa.py +695 -0
- evalrx/datasets/web_search_qa.py +52 -0
- evalrx/eval_agent/__init__.py +341 -0
- evalrx/eval_agent/_tools.py +81 -0
- evalrx/eval_agent/ab_runner.py +50 -0
- evalrx/eval_agent/agentic/__init__.py +43 -0
- evalrx/eval_agent/agentic/actions.py +216 -0
- evalrx/eval_agent/agentic/board.py +107 -0
- evalrx/eval_agent/agentic/loop.py +190 -0
- evalrx/eval_agent/agentic/tools.py +538 -0
- evalrx/eval_agent/checkpoint.py +57 -0
- evalrx/eval_agent/cli_agent.py +59 -0
- evalrx/eval_agent/cli_skills.py +5 -0
- evalrx/eval_agent/evolution.py +396 -0
- evalrx/eval_agent/git_manager.py +215 -0
- evalrx/eval_agent/hypothesis.py +172 -0
- evalrx/eval_agent/label_quarantine.py +209 -0
- evalrx/eval_agent/legacy.py +530 -0
- evalrx/eval_agent/log_schema.py +497 -0
- evalrx/eval_agent/loop.py +2159 -0
- evalrx/eval_agent/loop_reports.py +116 -0
- evalrx/eval_agent/model_instrumentation.py +282 -0
- evalrx/eval_agent/narration.py +193 -0
- evalrx/eval_agent/nl_runner.py +460 -0
- evalrx/eval_agent/orchestrator.py +61 -0
- evalrx/eval_agent/preregister.py +93 -0
- evalrx/eval_agent/prompts/__init__.py +1 -0
- evalrx/eval_agent/prompts/agentic.py +46 -0
- evalrx/eval_agent/prompts/case_discovery.py +25 -0
- evalrx/eval_agent/prompts/diagnosis.py +125 -0
- evalrx/eval_agent/prompts/experiment_writer.py +265 -0
- evalrx/eval_agent/prompts/explore_step.py +37 -0
- evalrx/eval_agent/prompts/fix_agent.py +257 -0
- evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
- evalrx/eval_agent/prompts/nl_runner.py +38 -0
- evalrx/eval_agent/prompts/probe_agent.py +25 -0
- evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
- evalrx/eval_agent/prompts/probe_generator.py +35 -0
- evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
- evalrx/eval_agent/report.py +58 -0
- evalrx/eval_agent/run_context.py +354 -0
- evalrx/eval_agent/run_log.schema.json +1215 -0
- evalrx/eval_agent/run_logger_v2.py +1764 -0
- evalrx/eval_agent/run_metadata.py +208 -0
- evalrx/eval_agent/stages/__init__.py +56 -0
- evalrx/eval_agent/stages/case_discovery.py +293 -0
- evalrx/eval_agent/stages/diagnosis.py +1017 -0
- evalrx/eval_agent/stages/experiment_writer.py +1634 -0
- evalrx/eval_agent/stages/fix_agent.py +3916 -0
- evalrx/eval_agent/stages/fix_internals.py +499 -0
- evalrx/eval_agent/stages/fix_pipeline.py +725 -0
- evalrx/eval_agent/stages/fix_tiers.py +187 -0
- evalrx/eval_agent/stages/fix_tools.py +1034 -0
- evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
- evalrx/eval_agent/stages/probe.py +439 -0
- evalrx/eval_agent/stages/probe_agent.py +1079 -0
- evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
- evalrx/eval_agent/stages/probe_generator.py +326 -0
- evalrx/eval_agent/stages/probe_search_agent.py +106 -0
- evalrx/eval_agent/stages/protocol.py +112 -0
- evalrx/eval_agent/stages/repair_catalog.py +273 -0
- evalrx/eval_agent/stages/surgery.py +524 -0
- evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
- evalrx/eval_agent/store.py +231 -0
- evalrx/logging_utils.py +112 -0
- evalrx/models/__init__.py +161 -0
- evalrx/models/_discover.py +101 -0
- evalrx/models/agent.py +380 -0
- evalrx/models/backends/__init__.py +58 -0
- evalrx/models/backends/api.py +169 -0
- evalrx/models/backends/base.py +57 -0
- evalrx/models/backends/gemini_compat.py +579 -0
- evalrx/models/backends/hf_local.py +2074 -0
- evalrx/models/backends/openai_compat.py +301 -0
- evalrx/models/backends/vllm_offline.py +116 -0
- evalrx/models/base.py +24 -0
- evalrx/models/blackbox/__init__.py +4 -0
- evalrx/models/blackbox/agent.py +31 -0
- evalrx/models/blackbox/base.py +29 -0
- evalrx/models/blackbox/gemini.py +279 -0
- evalrx/models/blackbox/llm_api.py +17 -0
- evalrx/models/blackbox/vlm_api.py +17 -0
- evalrx/models/compose.py +66 -0
- evalrx/models/inference.py +88 -0
- evalrx/models/paper_methods/__init__.py +8 -0
- evalrx/models/paper_methods/aad.py +53 -0
- evalrx/models/paper_methods/ifcd.py +204 -0
- evalrx/models/paper_methods/pai.py +164 -0
- evalrx/models/paper_methods/tcd.py +202 -0
- evalrx/models/paper_methods/vcd.py +45 -0
- evalrx/models/paper_methods/vicrop.py +137 -0
- evalrx/models/toolcodec.py +143 -0
- evalrx/models/tools/__init__.py +20 -0
- evalrx/models/tools/perception.py +300 -0
- evalrx/models/tools/visual.py +174 -0
- evalrx/models/whitebox/__init__.py +26 -0
- evalrx/models/whitebox/agent.py +31 -0
- evalrx/models/whitebox/base.py +24 -0
- evalrx/models/whitebox/qwen.py +61 -0
- evalrx/models/whitebox/qwen2_5_omni.py +29 -0
- evalrx/models/whitebox/qwen2_audio.py +25 -0
- evalrx/models/whitebox/qwen_omni.py +53 -0
- evalrx/models/whitebox/qwen_vl.py +62 -0
- evalrx/observability/__init__.py +21 -0
- evalrx/observability/envelope.py +122 -0
- evalrx/observability/outbox.py +111 -0
- evalrx/observability/tracer.py +882 -0
- evalrx/reporting/__init__.py +28 -0
- evalrx/reporting/case_study.py +947 -0
- evalrx/reporting/compiler.py +587 -0
- evalrx/reporting/dynamic.py +1882 -0
- evalrx/reporting/html_report.py +2225 -0
- evalrx/reporting/langfuse_exporter.py +38 -0
- evalrx/reporting/langfuse_source.py +155 -0
- evalrx/reporting/model.py +151 -0
- evalrx/reporting/run_events.py +184 -0
- evalrx/reporting/server.py +557 -0
- evalrx/reporting/stages.py +58 -0
- evalrx/reporting/static_export.py +142 -0
- evalrx/reporting/web_dist/index.html +146 -0
- evalrx/specs.py +727 -0
- evalrx/stats/__init__.py +47 -0
- evalrx/stats/api.py +192 -0
- evalrx/stats/bootstrap.py +86 -0
- evalrx/stats/ebh.py +27 -0
- evalrx/stats/evalue.py +98 -0
- evalrx/stats/friedman.py +138 -0
- evalrx/stats/mcnemar.py +40 -0
- evalrx/stats/multiplicity.py +159 -0
- evalrx/stats/subset_sampling.py +55 -0
- evalrx/term_links.py +43 -0
- evalrx/viz/__init__.py +7 -0
- evalrx/viz/labels.py +77 -0
- evalrx/viz/prompts.py +39 -0
- evalrx/viz/renderer.py +590 -0
- evalrx/viz/schema.py +36 -0
- evalrx/viz/style.py +134 -0
- evalrx-0.1.2.dist-info/METADATA +532 -0
- evalrx-0.1.2.dist-info/RECORD +339 -0
- evalrx-0.1.2.dist-info/WHEEL +5 -0
- evalrx-0.1.2.dist-info/entry_points.txt +3 -0
- evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
- evalrx-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Experiment sandbox — safe subprocess execution + ARC-compatible metric parsing.
|
|
2
|
+
|
|
3
|
+
Mirrors researchclaw/experiment/sandbox.py.
|
|
4
|
+
|
|
5
|
+
Metric output formats understood by ``parse_metrics``:
|
|
6
|
+
|
|
7
|
+
Plain: ``metric: 0.85``
|
|
8
|
+
Condition: ``condition=arithmetic metric: 0.42``
|
|
9
|
+
Summary: ``SUMMARY condition=X metric=Y mean=M std=S``
|
|
10
|
+
|
|
11
|
+
The diagnostic script printed by ``ExperimentWriter`` uses these same formats,
|
|
12
|
+
so ``SandboxResult.metrics`` is immediately ready for the surgery agent to
|
|
13
|
+
interpret.
|
|
14
|
+
|
|
15
|
+
Enhanced over the original with:
|
|
16
|
+
- ``SandboxProtocol`` for pluggable backends
|
|
17
|
+
- ``run_project()`` for multi-file experiment projects
|
|
18
|
+
- Path traversal protection (validate_entry_point / resolved)
|
|
19
|
+
- Numbered project dirs (``_project_{counter}``) with thread-safe counter
|
|
20
|
+
- Immutable harness injection (``experiment_harness.py``)
|
|
21
|
+
- Cleanup policy: delete scripts only on success
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import math
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import shutil
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
import tempfile
|
|
34
|
+
import threading
|
|
35
|
+
import time
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
38
|
+
from typing import Callable, Protocol
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Entry-point validation (path traversal protection)
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_entry_point(entry_point: str) -> str | None:
|
|
48
|
+
"""Validate *entry_point* syntax without filesystem access.
|
|
49
|
+
|
|
50
|
+
Returns an error message if invalid, ``None`` if valid.
|
|
51
|
+
Call this **before** copying files to fail fast on obviously bad input.
|
|
52
|
+
"""
|
|
53
|
+
if not entry_point or not entry_point.strip():
|
|
54
|
+
return "Entry point is empty"
|
|
55
|
+
ep = Path(entry_point)
|
|
56
|
+
posix_ep = PurePosixPath(entry_point)
|
|
57
|
+
windows_ep = PureWindowsPath(entry_point)
|
|
58
|
+
if ep.is_absolute() or posix_ep.is_absolute() or windows_ep.is_absolute():
|
|
59
|
+
return f"Entry point must be a relative path, got: {entry_point}"
|
|
60
|
+
if ".." in ep.parts:
|
|
61
|
+
return f"Entry point must not contain '..': {entry_point}"
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def validate_entry_point_resolved(staging: Path, entry_point: str) -> str | None:
|
|
66
|
+
"""Validate that *entry_point* resolves inside *staging*.
|
|
67
|
+
|
|
68
|
+
Returns an error message if invalid, ``None`` if valid.
|
|
69
|
+
Call this **after** copying files so that symlinks are resolved against
|
|
70
|
+
the real staging contents.
|
|
71
|
+
"""
|
|
72
|
+
resolved = (staging / entry_point).resolve()
|
|
73
|
+
staging_resolved = staging.resolve()
|
|
74
|
+
if not resolved.is_relative_to(staging_resolved):
|
|
75
|
+
return f"Entry point escapes staging directory: {entry_point}"
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Metric parsing (ARC-compatible formats)
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
_FLOAT_RE = r"[+-]?\d+\.?\d*(?:[eE][+-]?\d+)?"
|
|
84
|
+
|
|
85
|
+
# Plain: "metric: 0.85"
|
|
86
|
+
_PLAIN_RE = re.compile(rf"^(\w[\w./]*)\s*:\s*({_FLOAT_RE})\s*$")
|
|
87
|
+
|
|
88
|
+
# Condition: "condition=X metric: 0.42"
|
|
89
|
+
_CONDITION_RE = re.compile(rf"^condition=(\S+)\s+(\w[\w./]*)\s*:\s*({_FLOAT_RE})\s*$")
|
|
90
|
+
|
|
91
|
+
# Summary: "SUMMARY condition=X metric=Y mean=M std=S"
|
|
92
|
+
_SUMMARY_RE = re.compile(
|
|
93
|
+
rf"^SUMMARY\s+condition=(\S+)\s+metric=(\S+)\s+mean=({_FLOAT_RE})\s+std=({_FLOAT_RE})"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_metrics(stdout: str) -> dict[str, float]:
|
|
98
|
+
"""Extract ``name: float`` pairs from *stdout*.
|
|
99
|
+
|
|
100
|
+
Matching priority: SUMMARY lines first, then condition-prefixed, then plain.
|
|
101
|
+
Non-finite values are silently dropped.
|
|
102
|
+
"""
|
|
103
|
+
metrics: dict[str, float] = {}
|
|
104
|
+
|
|
105
|
+
for line in stdout.splitlines():
|
|
106
|
+
stripped = line.strip()
|
|
107
|
+
|
|
108
|
+
# SUMMARY condition=X metric=Y mean=M std=S
|
|
109
|
+
m = _SUMMARY_RE.match(stripped)
|
|
110
|
+
if m:
|
|
111
|
+
cond, metric, mean_s, std_s = m.groups()
|
|
112
|
+
try:
|
|
113
|
+
mean_v, std_v = float(mean_s), float(std_s)
|
|
114
|
+
except ValueError:
|
|
115
|
+
continue
|
|
116
|
+
if math.isfinite(mean_v):
|
|
117
|
+
metrics[f"{cond}/{metric}"] = mean_v
|
|
118
|
+
metrics[f"{cond}/{metric}_mean"] = mean_v
|
|
119
|
+
metrics[f"{cond}/{metric}_std"] = std_v
|
|
120
|
+
metrics[metric] = mean_v
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
# condition=X metric: value
|
|
124
|
+
m = _CONDITION_RE.match(stripped)
|
|
125
|
+
if m:
|
|
126
|
+
cond, name, val_s = m.groups()
|
|
127
|
+
try:
|
|
128
|
+
val = float(val_s)
|
|
129
|
+
except ValueError:
|
|
130
|
+
continue
|
|
131
|
+
if math.isfinite(val):
|
|
132
|
+
metrics[f"{cond}/{name}"] = val
|
|
133
|
+
metrics[name] = val
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
# metric: value
|
|
137
|
+
m = _PLAIN_RE.match(stripped)
|
|
138
|
+
if m:
|
|
139
|
+
name, val_s = m.groups()
|
|
140
|
+
try:
|
|
141
|
+
val = float(val_s)
|
|
142
|
+
except ValueError:
|
|
143
|
+
continue
|
|
144
|
+
if math.isfinite(val):
|
|
145
|
+
metrics[name] = val
|
|
146
|
+
|
|
147
|
+
return metrics
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
# Result + Protocol
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class SandboxResult:
|
|
157
|
+
"""Output of one :class:`ExperimentSandbox` run.
|
|
158
|
+
|
|
159
|
+
Attributes:
|
|
160
|
+
returncode: Process exit code (0 = success, −1 = internal/timeout).
|
|
161
|
+
stdout: Captured standard output.
|
|
162
|
+
stderr: Captured standard error.
|
|
163
|
+
elapsed_sec: Wall-clock seconds.
|
|
164
|
+
metrics: Parsed ``{name: float}`` from *stdout*.
|
|
165
|
+
timed_out: ``True`` when the process was killed for exceeding the budget.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
returncode: int
|
|
169
|
+
stdout: str
|
|
170
|
+
stderr: str
|
|
171
|
+
elapsed_sec: float
|
|
172
|
+
metrics: dict[str, float] = field(default_factory=dict)
|
|
173
|
+
timed_out: bool = False
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def ok(self) -> bool:
|
|
177
|
+
return self.returncode == 0 and not self.timed_out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class SandboxProtocol(Protocol):
|
|
181
|
+
"""Structural type for sandbox backends (subprocess, Docker, SSH, …)."""
|
|
182
|
+
|
|
183
|
+
def run(self, code: str, *, timeout_sec: int = 60) -> SandboxResult: ...
|
|
184
|
+
|
|
185
|
+
def run_project(
|
|
186
|
+
self,
|
|
187
|
+
project_dir: Path,
|
|
188
|
+
*,
|
|
189
|
+
entry_point: str = "main.py",
|
|
190
|
+
timeout_sec: int = 60,
|
|
191
|
+
args: list[str] | None = None,
|
|
192
|
+
env_overrides: dict[str, str] | None = None,
|
|
193
|
+
) -> SandboxResult: ...
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# ExperimentSandbox
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _make_resource_preexec(
|
|
202
|
+
max_memory_bytes: int | None,
|
|
203
|
+
max_cpu_seconds: int | None,
|
|
204
|
+
) -> "Callable[[], None] | None":
|
|
205
|
+
"""Return a ``preexec_fn`` that sets resource limits on Linux, or ``None``.
|
|
206
|
+
|
|
207
|
+
Uses ``resource.setrlimit`` which is only available on Unix. The function
|
|
208
|
+
is constructed at call time and captures the limit values in its closure so
|
|
209
|
+
each subprocess gets its own independent limits.
|
|
210
|
+
|
|
211
|
+
Memory is capped via ``RLIMIT_AS`` (virtual address space). CPU time is
|
|
212
|
+
capped via ``RLIMIT_CPU`` (user+system seconds). Hitting the CPU limit
|
|
213
|
+
sends SIGXCPU to the process; the subprocess.run *timeout* handles wall-clock
|
|
214
|
+
kills independently, so both limits are in effect simultaneously.
|
|
215
|
+
"""
|
|
216
|
+
import sys
|
|
217
|
+
if sys.platform == "win32":
|
|
218
|
+
return None
|
|
219
|
+
try:
|
|
220
|
+
import resource as _resource
|
|
221
|
+
except ImportError:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
def _preexec() -> None:
|
|
225
|
+
if max_memory_bytes is not None:
|
|
226
|
+
try:
|
|
227
|
+
_resource.setrlimit(
|
|
228
|
+
_resource.RLIMIT_AS,
|
|
229
|
+
(max_memory_bytes, max_memory_bytes),
|
|
230
|
+
)
|
|
231
|
+
except (ValueError, _resource.error):
|
|
232
|
+
pass
|
|
233
|
+
if max_cpu_seconds is not None:
|
|
234
|
+
try:
|
|
235
|
+
_resource.setrlimit(
|
|
236
|
+
_resource.RLIMIT_CPU,
|
|
237
|
+
(max_cpu_seconds, max_cpu_seconds),
|
|
238
|
+
)
|
|
239
|
+
except (ValueError, _resource.error):
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
return _preexec
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class ExperimentSandbox:
|
|
246
|
+
"""Run Python code in a subprocess and parse metrics from stdout.
|
|
247
|
+
|
|
248
|
+
Mirrors ``researchclaw.experiment.sandbox.ExperimentSandbox``.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
workdir: Directory where scripts are written. A temporary
|
|
252
|
+
directory is created when ``None``.
|
|
253
|
+
python_path: Python executable to use. Defaults to ``sys.executable``.
|
|
254
|
+
max_memory_bytes: Virtual-address-space cap applied via ``RLIMIT_AS``
|
|
255
|
+
before the child process starts (Linux/macOS only).
|
|
256
|
+
``None`` means no memory limit.
|
|
257
|
+
max_cpu_seconds: CPU-time cap applied via ``RLIMIT_CPU`` (Linux/macOS).
|
|
258
|
+
``None`` means no CPU limit. This complements the
|
|
259
|
+
wall-clock *timeout_sec* argument to ``run()``.
|
|
260
|
+
cleanup: When ``True`` (default), a successful run's script/
|
|
261
|
+
project is deleted (see ``_should_cleanup``) — the
|
|
262
|
+
original behaviour for an ephemeral *workdir*. Pass
|
|
263
|
+
``False`` when *workdir* is a durable, self-contained
|
|
264
|
+
location (e.g. one fix/M5 trial's ``workspace/``)
|
|
265
|
+
whose code should stay on disk even on success.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(
|
|
269
|
+
self,
|
|
270
|
+
workdir: Path | str | None = None,
|
|
271
|
+
*,
|
|
272
|
+
python_path: str | None = None,
|
|
273
|
+
max_memory_bytes: int | None = None,
|
|
274
|
+
max_cpu_seconds: int | None = None,
|
|
275
|
+
cleanup: bool = True,
|
|
276
|
+
) -> None:
|
|
277
|
+
if workdir is None:
|
|
278
|
+
workdir = Path(tempfile.mkdtemp(prefix="evalrx_sandbox_"))
|
|
279
|
+
# Resolved to absolute: _run_script passes this same path as both the
|
|
280
|
+
# subprocess cwd and (via script_path) part of the command argv — a
|
|
281
|
+
# relative workdir makes the child resolve the script path a second
|
|
282
|
+
# time relative to its new cwd, doubling it and raising
|
|
283
|
+
# FileNotFoundError instead of running the script.
|
|
284
|
+
self.workdir = Path(workdir).resolve()
|
|
285
|
+
self.workdir.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
self._python_path: str = python_path or sys.executable
|
|
287
|
+
self._run_counter: int = 0
|
|
288
|
+
self._counter_lock = threading.Lock()
|
|
289
|
+
self._preexec_fn = _make_resource_preexec(max_memory_bytes, max_cpu_seconds)
|
|
290
|
+
self._cleanup = cleanup
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# Public interface
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def run(self, code: str, *, timeout_sec: int = 60) -> SandboxResult:
|
|
297
|
+
"""Write *code* to a numbered script file and execute it.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
code: Python source to execute.
|
|
301
|
+
timeout_sec: Hard wall-clock limit; the process is killed on breach.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
:class:`SandboxResult` with parsed metrics.
|
|
305
|
+
"""
|
|
306
|
+
with self._counter_lock:
|
|
307
|
+
self._run_counter += 1
|
|
308
|
+
counter = self._run_counter
|
|
309
|
+
script_path = self.workdir / f"exp_{counter:04d}.py"
|
|
310
|
+
script_path.write_text(code, encoding="utf-8")
|
|
311
|
+
|
|
312
|
+
result = self._run_script(script_path, timeout_sec=timeout_sec)
|
|
313
|
+
|
|
314
|
+
if self._should_cleanup(result):
|
|
315
|
+
self._cleanup_path(script_path)
|
|
316
|
+
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
def run_project(
|
|
320
|
+
self,
|
|
321
|
+
project_dir: Path,
|
|
322
|
+
*,
|
|
323
|
+
entry_point: str = "main.py",
|
|
324
|
+
timeout_sec: int = 60,
|
|
325
|
+
args: list[str] | None = None,
|
|
326
|
+
env_overrides: dict[str, str] | None = None,
|
|
327
|
+
) -> SandboxResult:
|
|
328
|
+
"""Copy a multi-file project into the sandbox and execute *entry_point*.
|
|
329
|
+
|
|
330
|
+
Security:
|
|
331
|
+
- *entry_point* is validated for path traversal before and after copy.
|
|
332
|
+
- The project cannot overwrite the injected ``experiment_harness.py``.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
project_dir: Source directory containing project files.
|
|
336
|
+
entry_point: Relative path to the script to execute (default ``main.py``).
|
|
337
|
+
timeout_sec: Hard wall-clock limit.
|
|
338
|
+
args: Extra command-line arguments passed to the script.
|
|
339
|
+
env_overrides: Additional environment variables for the subprocess.
|
|
340
|
+
|
|
341
|
+
Returns:
|
|
342
|
+
:class:`SandboxResult` with parsed metrics.
|
|
343
|
+
"""
|
|
344
|
+
# Pre-copy syntax validation — fail fast on obviously bad input
|
|
345
|
+
err = validate_entry_point(entry_point)
|
|
346
|
+
if err:
|
|
347
|
+
return SandboxResult(returncode=-1, stdout="", stderr=err,
|
|
348
|
+
elapsed_sec=0.0, metrics={})
|
|
349
|
+
|
|
350
|
+
with self._counter_lock:
|
|
351
|
+
self._run_counter += 1
|
|
352
|
+
counter = self._run_counter
|
|
353
|
+
sandbox_project = self.workdir / f"_project_{counter}"
|
|
354
|
+
if sandbox_project.exists():
|
|
355
|
+
shutil.rmtree(sandbox_project)
|
|
356
|
+
sandbox_project.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
|
|
358
|
+
# Inject immutable experiment harness first
|
|
359
|
+
self._inject_harness(sandbox_project)
|
|
360
|
+
|
|
361
|
+
# Copy project files (harness cannot be overwritten)
|
|
362
|
+
for src in Path(project_dir).iterdir():
|
|
363
|
+
if src.is_file():
|
|
364
|
+
dest = sandbox_project / src.name
|
|
365
|
+
if dest.name == "experiment_harness.py":
|
|
366
|
+
logger.warning(
|
|
367
|
+
"Project contains experiment_harness.py — skipping (immutable)"
|
|
368
|
+
)
|
|
369
|
+
continue
|
|
370
|
+
dest.write_bytes(src.read_bytes())
|
|
371
|
+
elif src.is_dir() and not src.name.startswith("."):
|
|
372
|
+
shutil.copytree(src, sandbox_project / src.name, dirs_exist_ok=True)
|
|
373
|
+
|
|
374
|
+
# Post-copy resolve check — catches symlink-based escapes
|
|
375
|
+
err = validate_entry_point_resolved(sandbox_project, entry_point)
|
|
376
|
+
if err:
|
|
377
|
+
return SandboxResult(returncode=-1, stdout="", stderr=err,
|
|
378
|
+
elapsed_sec=0.0, metrics={})
|
|
379
|
+
|
|
380
|
+
entry = sandbox_project / entry_point
|
|
381
|
+
if not entry.exists():
|
|
382
|
+
return SandboxResult(
|
|
383
|
+
returncode=-1, stdout="",
|
|
384
|
+
stderr=f"Entry point '{entry_point}' not found in project",
|
|
385
|
+
elapsed_sec=0.0, metrics={},
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
result = self._run_script(
|
|
389
|
+
entry,
|
|
390
|
+
timeout_sec=timeout_sec,
|
|
391
|
+
cwd=sandbox_project,
|
|
392
|
+
args=args,
|
|
393
|
+
env_overrides=env_overrides,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
if self._should_cleanup(result):
|
|
397
|
+
self._cleanup_path(sandbox_project)
|
|
398
|
+
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
# ------------------------------------------------------------------
|
|
402
|
+
# Internals
|
|
403
|
+
# ------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
def _run_script(
|
|
406
|
+
self,
|
|
407
|
+
script_path: Path,
|
|
408
|
+
*,
|
|
409
|
+
timeout_sec: int,
|
|
410
|
+
cwd: Path | None = None,
|
|
411
|
+
args: list[str] | None = None,
|
|
412
|
+
env_overrides: dict[str, str] | None = None,
|
|
413
|
+
) -> SandboxResult:
|
|
414
|
+
env = {**os.environ, "PYTHONUNBUFFERED": "1"}
|
|
415
|
+
if env_overrides:
|
|
416
|
+
env.update(env_overrides)
|
|
417
|
+
command = self._build_command(script_path, args=args)
|
|
418
|
+
run_cwd = str(cwd or self.workdir)
|
|
419
|
+
start = time.monotonic()
|
|
420
|
+
try:
|
|
421
|
+
completed = subprocess.run(
|
|
422
|
+
command,
|
|
423
|
+
capture_output=True,
|
|
424
|
+
text=True,
|
|
425
|
+
encoding="utf-8",
|
|
426
|
+
errors="replace",
|
|
427
|
+
timeout=timeout_sec,
|
|
428
|
+
cwd=run_cwd,
|
|
429
|
+
env=env,
|
|
430
|
+
check=False,
|
|
431
|
+
preexec_fn=self._preexec_fn,
|
|
432
|
+
)
|
|
433
|
+
return self._result_from_completed(completed, elapsed_sec=time.monotonic() - start)
|
|
434
|
+
except subprocess.TimeoutExpired as exc:
|
|
435
|
+
return self._result_from_timeout(
|
|
436
|
+
exc, timeout_sec=timeout_sec, elapsed_sec=time.monotonic() - start
|
|
437
|
+
)
|
|
438
|
+
except Exception as exc: # noqa: BLE001
|
|
439
|
+
return self._result_from_exception(exc, elapsed_sec=time.monotonic() - start)
|
|
440
|
+
|
|
441
|
+
def _build_command(
|
|
442
|
+
self, script_path: Path, *, args: list[str] | None = None
|
|
443
|
+
) -> list[str]:
|
|
444
|
+
python = self._python_path
|
|
445
|
+
python_path = Path(python)
|
|
446
|
+
if not python_path.is_absolute() and python != "python":
|
|
447
|
+
python_path = Path.cwd() / python_path
|
|
448
|
+
command = [str(python_path), "-u", str(script_path)]
|
|
449
|
+
if args:
|
|
450
|
+
command.extend(args)
|
|
451
|
+
return command
|
|
452
|
+
|
|
453
|
+
@staticmethod
|
|
454
|
+
def _inject_harness(target_dir: Path) -> None:
|
|
455
|
+
"""Copy the immutable experiment harness into *target_dir*."""
|
|
456
|
+
harness_src = Path(__file__).parent / "experiment_harness.py"
|
|
457
|
+
if harness_src.exists():
|
|
458
|
+
dest = target_dir / "experiment_harness.py"
|
|
459
|
+
dest.write_text(harness_src.read_text(encoding="utf-8"), encoding="utf-8")
|
|
460
|
+
logger.debug("Injected experiment harness into %s", target_dir)
|
|
461
|
+
else:
|
|
462
|
+
logger.warning("Harness template not found at %s", harness_src)
|
|
463
|
+
|
|
464
|
+
@staticmethod
|
|
465
|
+
def _result_from_completed(
|
|
466
|
+
completed: subprocess.CompletedProcess[str], *, elapsed_sec: float
|
|
467
|
+
) -> SandboxResult:
|
|
468
|
+
stdout = completed.stdout or ""
|
|
469
|
+
stderr = completed.stderr or ""
|
|
470
|
+
return SandboxResult(
|
|
471
|
+
returncode=completed.returncode,
|
|
472
|
+
stdout=stdout,
|
|
473
|
+
stderr=stderr,
|
|
474
|
+
elapsed_sec=elapsed_sec,
|
|
475
|
+
metrics=parse_metrics(stdout),
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
@staticmethod
|
|
479
|
+
def _result_from_timeout(
|
|
480
|
+
exc: subprocess.TimeoutExpired,
|
|
481
|
+
*,
|
|
482
|
+
timeout_sec: int,
|
|
483
|
+
elapsed_sec: float,
|
|
484
|
+
) -> SandboxResult:
|
|
485
|
+
stdout = (exc.stdout or b"").decode("utf-8", errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
|
486
|
+
stderr = (exc.stderr or b"").decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
|
487
|
+
return SandboxResult(
|
|
488
|
+
returncode=-1,
|
|
489
|
+
stdout=stdout,
|
|
490
|
+
stderr=f"[TIMEOUT after {timeout_sec}s]\n{stderr}",
|
|
491
|
+
elapsed_sec=elapsed_sec,
|
|
492
|
+
metrics=parse_metrics(stdout),
|
|
493
|
+
timed_out=True,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
@staticmethod
|
|
497
|
+
def _result_from_exception(exc: Exception, *, elapsed_sec: float) -> SandboxResult:
|
|
498
|
+
return SandboxResult(
|
|
499
|
+
returncode=-1,
|
|
500
|
+
stdout="",
|
|
501
|
+
stderr=f"[SANDBOX ERROR] {exc}",
|
|
502
|
+
elapsed_sec=elapsed_sec,
|
|
503
|
+
metrics={},
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
def _should_cleanup(self, result: SandboxResult) -> bool:
|
|
507
|
+
return self._cleanup and result.returncode == 0 and not result.timed_out
|
|
508
|
+
|
|
509
|
+
@staticmethod
|
|
510
|
+
def _cleanup_path(path: Path) -> None:
|
|
511
|
+
try:
|
|
512
|
+
if path.is_dir():
|
|
513
|
+
shutil.rmtree(path)
|
|
514
|
+
else:
|
|
515
|
+
path.unlink(missing_ok=True)
|
|
516
|
+
except Exception: # noqa: BLE001
|
|
517
|
+
logger.warning("Failed to clean up sandbox path: %s", path)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Provider-neutral, evidence-backed auditing for configured Agent Skills.
|
|
2
|
+
|
|
3
|
+
The audit deliberately distinguishes a skill being requested or installed from
|
|
4
|
+
the provider trace proving it was invoked/read. A provider without a machine
|
|
5
|
+
readable trace is reported as ``not_observable`` rather than guessed from the
|
|
6
|
+
assistant's prose.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
AUDIT_SCHEMA_VERSION = 1
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _skill_names(paths: list[str] | tuple[str, ...]) -> list[str]:
|
|
19
|
+
return list(dict.fromkeys(Path(path).name for path in paths if Path(path).name))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _json_lines(stdout: str) -> list[dict[str, Any]]:
|
|
23
|
+
rows: list[dict[str, Any]] = []
|
|
24
|
+
for line in stdout.splitlines():
|
|
25
|
+
try:
|
|
26
|
+
item = json.loads(line)
|
|
27
|
+
except (TypeError, ValueError):
|
|
28
|
+
continue
|
|
29
|
+
if isinstance(item, dict):
|
|
30
|
+
rows.append(item)
|
|
31
|
+
return rows
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _claude_evidence(stdout: str, requested: list[str]) -> list[dict[str, str]]:
|
|
35
|
+
evidence: list[dict[str, str]] = []
|
|
36
|
+
requested_set = set(requested)
|
|
37
|
+
for event in _json_lines(stdout):
|
|
38
|
+
if event.get("type") != "assistant":
|
|
39
|
+
continue
|
|
40
|
+
content = (event.get("message") or {}).get("content") or []
|
|
41
|
+
for block in content:
|
|
42
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
43
|
+
continue
|
|
44
|
+
if block.get("name") != "Skill":
|
|
45
|
+
continue
|
|
46
|
+
inp = block.get("input") or {}
|
|
47
|
+
raw_name = str(inp.get("skill") or "").strip().lstrip("/")
|
|
48
|
+
if raw_name in requested_set:
|
|
49
|
+
evidence.append({
|
|
50
|
+
"skill": raw_name,
|
|
51
|
+
"kind": "native_skill_invocation",
|
|
52
|
+
"detail": "Claude Code Skill tool invocation",
|
|
53
|
+
})
|
|
54
|
+
return evidence
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _codex_evidence(stdout: str, requested: list[str]) -> list[dict[str, str]]:
|
|
58
|
+
"""Find conservative evidence that Codex read a vendored skill guide.
|
|
59
|
+
|
|
60
|
+
``codex exec --json`` exposes shell command events, but Codex has no
|
|
61
|
+
``Skill`` tool. Merely listing a directory is not evidence of use, so only
|
|
62
|
+
commands that name a skill's ``SKILL.md`` (or a file within its directory)
|
|
63
|
+
are admitted, excluding ``rg --files`` discovery commands.
|
|
64
|
+
"""
|
|
65
|
+
evidence: list[dict[str, str]] = []
|
|
66
|
+
for event in _json_lines(stdout):
|
|
67
|
+
item = event.get("item") if isinstance(event.get("item"), dict) else {}
|
|
68
|
+
if item.get("type") != "command_execution":
|
|
69
|
+
continue
|
|
70
|
+
command = str(item.get("command") or "")
|
|
71
|
+
if "rg --files" in command:
|
|
72
|
+
continue
|
|
73
|
+
for name in requested:
|
|
74
|
+
marker = f".claude/skills/{name}/"
|
|
75
|
+
if marker in command:
|
|
76
|
+
evidence.append({
|
|
77
|
+
"skill": name,
|
|
78
|
+
"kind": "vendored_skill_file_read",
|
|
79
|
+
"detail": "Codex command referenced a file in the vendored skill directory",
|
|
80
|
+
})
|
|
81
|
+
return evidence
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_agent_audit(
|
|
85
|
+
*,
|
|
86
|
+
provider: str,
|
|
87
|
+
skills: list[str] | tuple[str, ...],
|
|
88
|
+
workdir: Path,
|
|
89
|
+
stdout: str,
|
|
90
|
+
stderr: str,
|
|
91
|
+
returncode: int,
|
|
92
|
+
timed_out: bool,
|
|
93
|
+
elapsed_sec: float,
|
|
94
|
+
files: dict[str, str],
|
|
95
|
+
error: str | None,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
"""Build one durable audit record for a provider invocation."""
|
|
98
|
+
requested = _skill_names(skills)
|
|
99
|
+
installed = [
|
|
100
|
+
name for name in requested
|
|
101
|
+
if (workdir / ".claude" / "skills" / name / "SKILL.md").is_file()
|
|
102
|
+
]
|
|
103
|
+
if provider == "claude_code":
|
|
104
|
+
evidence = _claude_evidence(stdout, requested)
|
|
105
|
+
observation = "native_skill_events"
|
|
106
|
+
elif provider == "codex":
|
|
107
|
+
evidence = _codex_evidence(stdout, requested)
|
|
108
|
+
observation = "json_command_events"
|
|
109
|
+
elif provider == "antigravity":
|
|
110
|
+
evidence = []
|
|
111
|
+
observation = "not_observable"
|
|
112
|
+
else:
|
|
113
|
+
evidence = []
|
|
114
|
+
observation = "not_observable"
|
|
115
|
+
|
|
116
|
+
invoked = list(dict.fromkeys(item["skill"] for item in evidence))
|
|
117
|
+
if timed_out:
|
|
118
|
+
status = "timed_out"
|
|
119
|
+
elif error:
|
|
120
|
+
status = "failed"
|
|
121
|
+
elif files:
|
|
122
|
+
status = "completed"
|
|
123
|
+
else:
|
|
124
|
+
status = "completed_without_code"
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"schema_version": AUDIT_SCHEMA_VERSION,
|
|
128
|
+
"provider": provider,
|
|
129
|
+
"skills_requested": requested,
|
|
130
|
+
"skills_installed": installed,
|
|
131
|
+
"skills_invoked": invoked,
|
|
132
|
+
"skill_observability": observation,
|
|
133
|
+
"evidence": evidence,
|
|
134
|
+
"execution": {
|
|
135
|
+
"status": status,
|
|
136
|
+
"returncode": returncode,
|
|
137
|
+
"timed_out": timed_out,
|
|
138
|
+
"elapsed_sec": round(elapsed_sec, 3),
|
|
139
|
+
"produced_files": sorted(files),
|
|
140
|
+
"error": error or "",
|
|
141
|
+
"stderr": stderr[:4000],
|
|
142
|
+
},
|
|
143
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Skill resolution, installation, and prompt policy helpers."""
|
|
2
|
+
|
|
3
|
+
from evalrx.agent_runtime.skills.installer import CodexSkillInstaller, SkillInstaller
|
|
4
|
+
from evalrx.agent_runtime.skills.prompt_policy import fences_hint, skills_hint
|
|
5
|
+
from evalrx.agent_runtime.skills.resolver import (
|
|
6
|
+
SKILL_BACKENDS,
|
|
7
|
+
bundled_skill_paths,
|
|
8
|
+
resolve_skill_paths,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CodexSkillInstaller",
|
|
13
|
+
"SKILL_BACKENDS",
|
|
14
|
+
"SkillInstaller",
|
|
15
|
+
"bundled_skill_paths",
|
|
16
|
+
"fences_hint",
|
|
17
|
+
"resolve_skill_paths",
|
|
18
|
+
"skills_hint",
|
|
19
|
+
]
|