Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 19 additions & 10 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,19 @@
FROM python:3.12-slim

# System deps
ARG NODE_MAJOR=20
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
ca-certificates \
gnupg \
libgomp1 \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

# CLI tools for harnesses that need external binaries
Expand All @@ -27,15 +35,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# goose — tarball from GitHub releases (goose harness spawns this)
RUN npm install -g @anthropic-ai/claude-code opencode-ai @openai/codex && npm cache clean --force

ARG GOOSE_VERSION=v1.33.1
RUN ARCH=$(uname -m) && \
GOOSE_VER=$(curl -sL "https://api.github.com/repos/block/goose/releases/latest" \
| grep -m1 '"tag_name"' | cut -d'"' -f4) && \
if [ -z "$GOOSE_VER" ]; then \
GOOSE_VER=$(curl -sL "https://api.github.com/repos/block/goose/releases" \
| grep -m1 '"tag_name"' | cut -d'"' -f4); \
fi && \
echo "Installing goose ${GOOSE_VER} for ${ARCH}" && \
curl -fsSL "https://github.com/block/goose/releases/download/${GOOSE_VER}/goose-${ARCH}-unknown-linux-gnu.tar.gz" \
echo "Installing goose ${GOOSE_VERSION} for ${ARCH}" && \
curl -fsSL "https://github.com/block/goose/releases/download/${GOOSE_VERSION}/goose-${ARCH}-unknown-linux-gnu.tar.gz" \
| tar xz -C /usr/local/bin/

# Python deps — core + all harness SDKs.
Expand All @@ -58,9 +61,15 @@ RUN pip install --no-cache-dir \
"hatchling" \
"daytona>=0.1.0"

# Non-root user
RUN useradd -m -s /bin/bash evoskill \
&& mkdir -p /workspace && chown evoskill:evoskill /workspace

# Git config for bundle operations
RUN git config --global user.email "evoskill@sandbox" \
&& git config --global user.name "EvoSkill Sandbox" \
&& git config --global init.defaultBranch main

WORKDIR /workspace
USER evoskill
ENV PATH="/home/evoskill/.local/bin:${PATH}"
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ Notes:
| `multi_tolerance` | Flexible string matching: exact, numeric tolerance, list overlap (default) |
| `exact` | Case-insensitive exact string match |
| `llm` | LLM-as-judge grading with a custom rubric |
| `script` | Shell script scorer — receives `{predicted}` and `{expected}` as variables |
| `script` | Shell script scorer — receives `PREDICTED` and `EXPECTED` as environment variables |

**LLM scorer options:**

Expand All @@ -423,10 +423,20 @@ For OpenRouter-backed scoring, set `provider = "openrouter"` and use an OpenRout

**Script scorer options:**

The script scorer passes the agent's prediction and expected answer as environment variables `PREDICTED` and `EXPECTED`. Your script should read these from the environment (not from command-line arguments):

```toml
[scorer]
type = "script"
command = "python score.py --predicted {predicted} --expected {expected}"
command = "python score.py"
```

```python
# score.py
import os
predicted = os.environ["PREDICTED"]
expected = os.environ["EXPECTED"]
print(1.0 if predicted.strip().lower() == expected.strip().lower() else 0.0)
```

## Remote Execution
Expand Down Expand Up @@ -597,6 +607,26 @@ If you use EvoSkill in your research, please cite the [original paper](https://a
}
```

## Security Considerations

EvoSkill evaluates LLM-generated code and output. Treat all agent responses as **untrusted input**.

### Trust model

- **Agent output is adversarial.** Agents under evaluation may produce responses designed to manipulate scoring, escape sandboxes, or exfiltrate data. Never interpolate agent output into shell commands, file paths, or other interpreted contexts.
- **Skill generation writes files.** The skill generator agent creates files on disk. Review generated skills before deploying them in production.
- **Remote execution runs untrusted code.** Docker and Daytona sandboxes execute LLM-generated code with access to forwarded API keys. Use scoped or short-lived API keys for remote runs.

### Safe configuration

- **Script scorer:** The scorer passes agent predictions via environment variables (`PREDICTED`, `EXPECTED`), not command-line interpolation. Your scorer script should read `os.environ["PREDICTED"]` — do not use `{predicted}` placeholders in the command string.
- **API keys:** Use environment variables (`ANTHROPIC_API_KEY`, `DAYTONA_API_KEY`, etc.) rather than storing keys in config files. The `.evoskill/` directory is gitignored by default, but custom config paths may not be.
- **Docker:** The default Dockerfile runs as a non-root user. If you customize the Dockerfile, maintain a non-root `USER` directive.

### Reporting vulnerabilities

If you discover a security issue, please report it to the maintainers via a GitHub security advisory rather than a public issue.

## License

This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details.
32 changes: 16 additions & 16 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,23 @@ description = "self-improving agent via skills"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"opencode-ai>=0.1.0a36",
"openai-codex-sdk>=0.1.11",
"claude-agent-sdk>=0.1.16",
"openhands-sdk>=1.16.1",
"openhands-tools>=1.16.1",
"click>=8.1.8",
"httpx>=0.23.0",
"pandas>=2.3.3",
"pydantic>=2.12.5",
"pydantic-settings>=2.12.0",
"pyyaml>=6.0",
"questionary>=2.1.1",
"rich>=14.2.0",
"tqdm>=4.60.0",
"tomli-w>=1.2.0",
"opencode-ai>=0.1.0a36,<1.0",
"openai-codex-sdk>=0.1.11,<1.0",
"claude-agent-sdk>=0.1.16,<1.0",
"openhands-sdk>=1.16.1,<2.0",
"openhands-tools>=1.16.1,<2.0",
"click>=8.1.8,<9.0",
"httpx>=0.23.0,<1.0",
"pandas>=2.3.3,<3.0",
"pydantic>=2.12.5,<3.0",
"pydantic-settings>=2.12.0,<3.0",
"pyyaml>=6.0,<7.0",
"questionary>=2.1.1,<3.0",
"rich>=14.2.0,<15.0",
"tqdm>=4.60.0,<5.0",
"tomli-w>=1.2.0,<2.0",
"hatchling",
"daytona>=0.1.0",
"daytona>=0.1.0,<1.0",
]

[project.scripts]
Expand Down
6 changes: 0 additions & 6 deletions src/agent_profiles/skill_generator/skill_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,9 @@ def get_project_root() -> str:
SKILL_GENERATOR_TOOLS = [
"Read",
"Write",
"Bash",
"Glob",
"Grep",
"Edit",
"WebFetch",
"WebSearch",
"TodoWrite",
"BashOutput",
"Skill",
]


Expand Down
2 changes: 1 addition & 1 deletion src/api/eval_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ def __init__(
task: str = "base",
*,
model: str | None = None,
output: str = "results/eval_results.pkl",
output: str = "results/eval_results.json",
max_concurrent: int = 8,
resume: bool = True,
num_samples: int | None = None,
Expand Down
6 changes: 6 additions & 0 deletions src/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,12 @@ def load_config(
env_key = os.environ.get('DAYTONA_API_KEY')
if env_key:
daytona_cfg.api_key = env_key
elif daytona_cfg is not None and daytona_cfg.api_key:
import logging
logging.getLogger(__name__).warning(
"Daytona API key loaded from config file — "
"prefer the DAYTONA_API_KEY environment variable to avoid committing secrets"
)
elif target == 'daytona' and daytona_cfg is None:
# target is daytona but no [remote.daytona] section — create with defaults
env_key = os.environ.get('DAYTONA_API_KEY')
Expand Down
9 changes: 5 additions & 4 deletions src/cli/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,16 +224,17 @@ def llm_scorer(question: str, predicted: str, ground_truth: str) -> float:
)

def script_scorer(question: str, predicted: str, ground_truth: str) -> float:
# Use Template-style substitution to avoid KeyError on { } in answers
cmd = cfg.scorer.command.replace("{predicted}", predicted).replace("{expected}", ground_truth)
import os
base_cmd = shlex.split(cfg.scorer.command)
env = {**os.environ, "PREDICTED": predicted, "EXPECTED": ground_truth}
try:
result = subprocess.run(
shlex.split(cmd), capture_output=True, text=True, timeout=30,
base_cmd, capture_output=True, text=True, timeout=30, env=env,
)
except subprocess.TimeoutExpired:
import logging
logging.getLogger(__name__).warning(
"Script scorer timed out (30s): %s", cmd[:120],
"Script scorer timed out (30s): %s", cfg.scorer.command,
)
return 0.0
try:
Expand Down
36 changes: 17 additions & 19 deletions src/docker/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ def _build_compose(cfg: ProjectConfig, extra_args: list[str]) -> dict:
env_forward = [k for k in _API_KEY_VARS if k in os.environ]

# Build the evoskill run command
import shlex
cmd = "pip install --no-deps -e . > /dev/null 2>&1 && evoskill run"
if extra_args:
cmd += " " + " ".join(extra_args)
cmd += " " + " ".join(shlex.quote(a) for a in extra_args)

return {
"services": {
Expand All @@ -87,28 +88,25 @@ def _build_compose(cfg: ProjectConfig, extra_args: list[str]) -> dict:

def _write_compose(cfg: ProjectConfig, compose: dict) -> Path:
"""Write docker-compose.yml to .evoskill/."""
import yaml

compose_path = cfg.evoskill_dir / COMPOSE_FILE
svc = compose["services"]["evoskill"]

lines = ["services:", " evoskill:"]
lines.append(f' image: {svc["image"]}')
lines.append(f' container_name: {svc["container_name"]}')
lines.append(f' working_dir: {svc["working_dir"]}')

lines.append(" volumes:")
for v in svc["volumes"]:
lines.append(f" - '{v}'")

lines.append(" environment:")
for e in svc["env_with_values"]:
lines.append(f" - '{e}'")
for k in svc["env_forward"]:
lines.append(f" - {k}")

lines.append(f" command: {svc['command']}")
lines.append("")
compose_dict = {
"services": {
"evoskill": {
"image": svc["image"],
"container_name": svc["container_name"],
"working_dir": svc["working_dir"],
"volumes": svc["volumes"],
"environment": svc["env_with_values"] + svc["env_forward"],
"command": svc["command"],
}
}
}

compose_path.write_text("\n".join(lines))
compose_path.write_text(yaml.safe_dump(compose_dict, default_flow_style=False))
return compose_path


Expand Down
58 changes: 47 additions & 11 deletions src/evaluation/eval_full.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import asyncio
import pickle
from dataclasses import dataclass
import json
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Generic, TypeVar

Expand All @@ -22,12 +22,51 @@ class IndexedEvalResult(Generic[T]):
error: str | None # Error message if failed


def _serialize_results(results: list[IndexedEvalResult]) -> str:
serialized = []
for r in results:
d = {
"index": r.index,
"question": r.question,
"ground_truth": r.ground_truth,
"error": r.error,
"trace": r.trace.model_dump(mode="json") if r.trace else None,
}
serialized.append(d)
return json.dumps(serialized, default=str)


def _deserialize_results(data: str) -> list[IndexedEvalResult]:
items = json.loads(data)
results = []
for d in items:
trace = None
if d.get("trace") is not None:
try:
trace = AgentTrace.model_validate(d["trace"])
except Exception:
pass
results.append(IndexedEvalResult(
index=d["index"],
question=d["question"],
ground_truth=d["ground_truth"],
trace=trace,
error=d.get("error"),
))
return results


def load_results(path: Path) -> list[IndexedEvalResult]:
"""Load results from pkl file."""
"""Load results from JSON file."""
if not path.exists():
return []
with open(path, "rb") as f:
return pickle.load(f)
with open(path, "r") as f:
return _deserialize_results(f.read())


def _save_results(path: Path, results: list[IndexedEvalResult]) -> None:
with open(path, "w") as f:
f.write(_serialize_results(results))


def get_successful_indices(path: Path) -> set[int]:
Expand All @@ -52,7 +91,7 @@ async def evaluate_full(
Args:
agent: The agent to evaluate
items: List of (index, question, ground_truth) tuples
output_path: Path to save pkl results
output_path: Path to save results (JSON)
max_concurrent: Max concurrent agent runs (default 5)
resume: If True, skip already-processed indices

Expand All @@ -71,9 +110,7 @@ async def evaluate_full(
existing = load_results(output_path)
kept_results = [r for r in existing if r.index not in failed_indices]
if len(kept_results) < len(existing):
# Some failed results were removed, save the cleaned file
with open(output_path, "wb") as f:
pickle.dump(kept_results, f)
_save_results(output_path, kept_results)
print(f"Removed {len(existing) - len(kept_results)} failed results for re-run")

items = items_to_run
Expand Down Expand Up @@ -115,8 +152,7 @@ async def run_one(
async with lock:
existing = load_results(output_path)
existing.append(result)
with open(output_path, "wb") as f:
pickle.dump(existing, f)
_save_results(output_path, existing)

return result

Expand Down
11 changes: 3 additions & 8 deletions src/harness/opencode/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,9 @@ def _kill_pid(pid: int) -> None:


def _kill_all_opencode_servers() -> None:
"""Kill all opencode serve processes on this machine."""
try:
subprocess.run(
["pkill", "-f", "opencode serve"],
capture_output=True, timeout=5,
)
except Exception:
pass
"""Kill all tracked opencode serve processes from this process."""
for pid in list(_SERVER_PIDS.values()):
_kill_pid(pid)


def shutdown_project_server(project_root: str | Path | None) -> None:
Expand Down
2 changes: 1 addition & 1 deletion src/registry/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ def _write_config(self, config: ProgramConfig) -> None:
config_path = self.cwd / self.PROGRAM_FILE
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w") as f:
yaml.dump(config.model_dump(), f, default_flow_style=False, sort_keys=False)
yaml.safe_dump(config.model_dump(), f, default_flow_style=False, sort_keys=False)

def _read_config(self) -> ProgramConfig:
"""Read program config from YAML file."""
Expand Down
Loading