Skip to content

Commit c052393

Browse files
committed
Add root agent user support for Oracle verification and refine task policies
- Introduce `--agent-user` option in `harbor export` to enable Oracle-verifiable tasks requiring root access. - Refine `Dockerfile` to drop unnecessary `NOPASSWD` sudo access for enhanced security. - Update `task.toml` to hand off the full workspace without exclusions, avoiding symlink issues. - Add paired-difference summaries for language comparisons with descriptive 95% confidence intervals. - Pin agent CLI version for experiment consistency, avoiding run-to-run confounds. - Expand agent image with inspection tools (`file`, `xxd`) necessary for trials.
1 parent 86bb998 commit c052393

9 files changed

Lines changed: 336 additions & 42 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,4 @@ pyrightconfig.json
176176
# End of https://www.toptal.com/developers/gitignore/api/python
177177
/jobs/
178178
/experiments/
179+
.idea

‎src/kotlinai/README.md‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,18 @@ uv run programbench harbor export ./out --filter '^svenstaro__' --slice 0:5
4545
uv run programbench harbor export ./out --target-language Kotlin <id>
4646
# Restrict the agent's network allowlist to a specific model API host
4747
uv run programbench harbor export ./out <id> --allowed-host api.openai.com
48+
# Export an oracle-verifiable task (the golden solve.sh needs root; see below)
49+
uv run programbench harbor export ./out <id> --agent-user root
4850
```
4951

52+
The agent phase runs as the cleanroom image's non-root `agent` user, so the
53+
mode-111 reference binary at `/workspace/executable` can be executed but not
54+
read, copied, or statically inspected; the generated agent image also drops the
55+
image's NOPASSWD sudo grant, which would otherwise hand root back. The agent's
56+
network allowlist covers the model API only — Harbor installs the agent CLI
57+
during *setup*, which runs before the agent-phase policy applies, so no
58+
package-registry or source-hosting host belongs on that list.
59+
5060
## Language comparison experiment
5161

5262
`scripts/language_comparison/run_language_comparison.sh` reads the languages,
@@ -69,8 +79,14 @@ comparisons of reward, cost, agent steps, and tokens.
6979

7080
The config must contain exactly two languages, one agent, positive parallelism,
7181
and an explicit `tasks.task_names` list. `parallelism` controls concurrent trials
72-
globally across both languages. Report deltas are the second language minus the
73-
first.
82+
globally across both languages; keep `parallelism * harbor.DOCKER_CPUS` inside
83+
the host's core count. Trials are submitted with the languages interleaved task
84+
by task, so drift in host load or model serving over a long run cannot align with
85+
the language contrast. Each run also resolves and pins one agent CLI version for
86+
all of its trials, recorded in `manifest.json` and the derived job config; set
87+
`agent.kwargs.version` yourself to skip the registry lookup. Report deltas are
88+
the second language minus the first, and `report.md` carries a paired-difference
89+
interval over the per-task deltas.
7490
Each experiment keeps its generated tasks, derived configs, Harbor jobs,
7591
`manifest.json`, `report.md`, and `report.json` under its output directory.
7692

@@ -96,9 +112,10 @@ Each instance produces a standard Harbor task directory:
96112

97113
ProgramBench's environment ships no source, but a working reference build sits
98114
at the canonical path the tests target — `/workspace/executable` — execute-only
99-
so a non-root agent can't read its bytes. The oracle (which runs as root)
100-
reconstructs `./executable` from it with **no network** and no per-language
101-
vendoring:
115+
so a non-root agent can't read its bytes. The oracle reads them, so it needs
116+
root: Harbor runs `solve.sh` as the task's `[agent] user`, which means oracle
117+
verification requires a task exported with `--agent-user root`. It then
118+
reconstructs `./executable` with **no network** and no per-language vendoring:
102119

103120
1. `solve.sh` base64-encodes `/workspace/executable` into
104121
`/workspace/.programbench_oracle_reference`.
@@ -137,6 +154,7 @@ if the JUnit XML marks it passed (absent counts as unresolved). It writes:
137154
still needs network access while building the task images.
138155

139156
```bash
157+
uv run programbench harbor export ./out <instance_id> --agent-user root
140158
harbor trials start --path ./out/<instance_id> --agent oracle
141159
```
142160

‎src/kotlinai/cli/harbor.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import typer
1010
import yaml
1111

12+
from kotlinai.harbor import AGENT_USER
13+
1214
app = typer.Typer(
1315
name="harbor",
1416
help="Export and compare tasks in the Harbor task format (harborframework.com).",
@@ -32,6 +34,12 @@ def export(
3234
"--target-language",
3335
help="Language the agent must use for its implementation; defaults to the instance language.",
3436
),
37+
agent_user: str = typer.Option(
38+
AGENT_USER,
39+
"--agent-user",
40+
help="User the agent phase runs as. The default cannot read the execute-only reference binary; "
41+
"pass 'root' to export an oracle-verifiable task (the golden solve.sh must read it).",
42+
),
3543
) -> None:
3644
"""Convert ProgramBench instances into Harbor tasks under OUT_DIR."""
3745
from kotlinai.harbor import convert_all
@@ -49,6 +57,7 @@ def _skip(instance_id: str, exc: Exception) -> None:
4957
slice_spec=slice_spec,
5058
allowed_hosts=allowed_host or None,
5159
target_language=target_language,
60+
agent_user=agent_user,
5261
on_error=_skip,
5362
)
5463
for p in paths:

‎src/kotlinai/data/templates/harbor_environment.Dockerfile.j2‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ FROM {{ image_name }}:{{ image_tag }}
55
{% if install_kotlin %}
66
RUN sed -i 's#https://snapshot.ubuntu.com/ubuntu/[0-9TZ]*/#http://archive.ubuntu.com/ubuntu/#g' /etc/apt/sources.list \
77
&& apt-get update \
8-
&& apt-get install -y --no-install-recommends ca-certificates curl openjdk-21-jdk-headless ripgrep unzip \
8+
&& apt-get install -y --no-install-recommends ca-certificates curl file openjdk-21-jdk-headless ripgrep unzip xxd \
99
&& curl -fsSLo /tmp/kotlin-compiler.zip \
1010
"https://github.com/JetBrains/kotlin/releases/download/v{{ kotlin_version }}/kotlin-compiler-{{ kotlin_version }}.zip" \
1111
&& echo "{{ kotlin_compiler_sha256 }} /tmp/kotlin-compiler.zip" | sha256sum -c - \
@@ -15,14 +15,20 @@ RUN sed -i 's#https://snapshot.ubuntu.com/ubuntu/[0-9TZ]*/#http://archive.ubuntu
1515
{% elif install_jdk %}
1616
RUN sed -i 's#https://snapshot.ubuntu.com/ubuntu/[0-9TZ]*/#http://archive.ubuntu.com/ubuntu/#g' /etc/apt/sources.list \
1717
&& apt-get update \
18-
&& apt-get install -y --no-install-recommends openjdk-21-jdk-headless ripgrep \
18+
&& apt-get install -y --no-install-recommends file openjdk-21-jdk-headless ripgrep xxd \
1919
&& rm -rf /var/lib/apt/lists/*
2020
{% else %}
2121
RUN sed -i 's#https://snapshot.ubuntu.com/ubuntu/[0-9TZ]*/#http://archive.ubuntu.com/ubuntu/#g' /etc/apt/sources.list \
2222
&& apt-get update \
23-
&& apt-get install -y --no-install-recommends ripgrep \
23+
&& apt-get install -y --no-install-recommends file ripgrep xxd \
2424
&& rm -rf /var/lib/apt/lists/*
2525
{% endif %}
26+
# The cleanroom image grants the non-root agent NOPASSWD sudo for apt-get, dpkg,
27+
# pip, npm, cargo and go — each an arbitrary-code-as-root path (e.g. an apt
28+
# Pre-Invoke hook), which would hand back the ability to read the execute-only
29+
# reference binary. Nothing legitimate needs it: the toolchains are baked in
30+
# above and the agent phase has no network.
31+
RUN rm -f /etc/sudoers.d/agent-packages
2632
{% if install_jdk %}
2733
ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
2834
{% endif %}

‎src/kotlinai/data/templates/harbor_task.toml.j2‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
version = "1.0"
2-
# Hand the agent's workspace to the separate verifier, excluding any prebuilt
3-
# ./executable so the offline build must reproduce it (matches ProgramBench's
4-
# submission archive, which the evaluator wipes ./executable from before compile).
5-
artifacts = [{ source = "/workspace", exclude = ["executable"] }]
2+
# Hand the agent's workspace to the separate verifier. tests/test.sh drops any
3+
# prebuilt ./executable before it runs compile.sh, so the offline build still
4+
# has to reproduce it (matching ProgramBench, whose evaluator wipes ./executable
5+
# from the submission archive before compiling).
6+
#
7+
# Deliberately no `exclude`: Harbor only reaches for its tar-based transfer when
8+
# an artifact has exclusions, and that path extracts host-side with
9+
# `tarfile.extractall(filter="data")`, which raises AbsoluteLinkError on any
10+
# absolute symlink in the tree. An agent aliasing the reference binary
11+
# (`ln -s /workspace/executable /workspace/refbin`) therefore lost its whole
12+
# workspace handoff and scored 0. With no exclusions Harbor uses `docker cp`,
13+
# which is indifferent to symlinks.
14+
artifacts = ["/workspace"]
615

716
[metadata]
817
author_name = "ProgramBench"
@@ -15,17 +24,26 @@ upstream_commit = "{{ commit }}"
1524
{% endif %}solution_explanation = "The reference source is intentionally removed; the agent must reverse-engineer the installed black-box binary and ship a compile.sh that rebuilds it offline."
1625
verification_explanation = "A separate verifier container (built from the cleanroom image with /workspace wiped) receives the agent's /workspace as an artifact minus ./executable, rebuilds ./executable from compile.sh with no network, then runs each active test branch's behavioral suite against it. Reward is the fraction of non-ignored tests that pass."
1726

18-
# No network during inference: the agent must reverse-engineer the binary, not
19-
# fetch upstream source. The agent phase overrides the baseline to an
20-
# LLM-API-only allowlist (requires a dynamic_network_policy environment backend,
21-
# e.g. Docker/OrbStack on Linux with nftables).
27+
# The environment baseline stays public so Harbor can build the image and install
28+
# the agent CLI (agent setup runs before the agent-phase policy applies).
2229
[environment]
2330
build_timeout_sec = {{ build_timeout }}
2431
cpus = {{ env_cpus }}
2532
network_mode = "public"
2633

34+
# No network during inference beyond the model API: the agent must
35+
# reverse-engineer the binary, not fetch upstream source. Overriding the baseline
36+
# per phase requires a dynamic_network_policy environment backend (e.g.
37+
# Docker/OrbStack on Linux with nftables).
38+
#
39+
# The agent runs as {{ agent_user }}. The default is a non-root user, so the
40+
# mode-111 reference binary at /workspace/executable can be executed but not
41+
# read, copied, or statically inspected; root would bypass those mode bits
42+
# entirely and is only for oracle verification, whose golden solve.sh must read
43+
# the reference (`harbor export --agent-user root`).
2744
[agent]
2845
timeout_sec = {{ agent_timeout }}
46+
user = "{{ agent_user }}"
2947
network_mode = "allowlist"
3048
allowed_hosts = {{ allowed_hosts | tojson }}
3149

‎src/kotlinai/experiment.py‎

Lines changed: 117 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
import copy
1212
import hashlib
1313
import json
14+
import math
1415
import os
1516
import re
17+
import statistics
1618
import subprocess
19+
import urllib.request
1720
from dataclasses import dataclass
1821
from datetime import datetime, timezone
1922
from pathlib import Path
@@ -23,7 +26,7 @@
2326

2427
from kotlinai.harbor import convert_all
2528

26-
REPORT_SCHEMA_VERSION = 2
29+
REPORT_SCHEMA_VERSION = 3
2730
RESERVED_HARBOR_FIELDS = {
2831
"agents",
2932
"datasets",
@@ -35,10 +38,45 @@
3538
}
3639

3740

41+
# npm packages the supported agent CLIs are published as. Harbor installs
42+
# `@latest` unless an explicit version is passed, so a release landing mid-run
43+
# would apply to whichever trials happen to start after it — with two languages
44+
# in one job that is a confound, not just noise.
45+
AGENT_VERSION_PACKAGES = {"codex": "@openai/codex"}
46+
47+
# Two-sided 95% Student's t critical values indexed by degrees of freedom (entry
48+
# 0 is unused). Beyond the table the value has converged on the normal quantile.
49+
# fmt: off
50+
T_CRITICAL_95 = (
51+
0.0, 12.706, 4.303, 3.182, 2.776, 2.571, 2.447, 2.365, 2.306, 2.262, 2.228,
52+
2.201, 2.179, 2.160, 2.145, 2.131, 2.120, 2.110, 2.101, 2.093, 2.086,
53+
2.080, 2.074, 2.069, 2.064, 2.060, 2.056, 2.052, 2.048, 2.045, 2.042,
54+
)
55+
# fmt: on
56+
57+
3858
class ComparisonError(ValueError):
3959
"""Raised when an experiment cannot guarantee a fair comparison."""
4060

4161

62+
def resolve_agent_version(package: str) -> str:
63+
"""Return the version npm currently publishes as ``latest`` for a package."""
64+
with urllib.request.urlopen(f"https://registry.npmjs.org/{package}/latest", timeout=30) as response:
65+
return json.load(response)["version"]
66+
67+
68+
def pin_agent_version(agent: dict[str, Any], resolver: Callable[[str], str]) -> None:
69+
"""Pin ``agent`` to one CLI version for the whole run.
70+
71+
A no-op when there is nothing to pin: the agent is not one we know an npm
72+
package for, or the caller already pinned it explicitly.
73+
"""
74+
package = AGENT_VERSION_PACKAGES.get(agent["name"].strip().lower())
75+
kwargs = agent.setdefault("kwargs", {})
76+
if package is not None and not kwargs.get("version"):
77+
kwargs["version"] = resolver(package)
78+
79+
4280
@dataclass(frozen=True)
4381
class ComparisonSettings:
4482
languages: tuple[str, str]
@@ -140,7 +178,21 @@ def build_comparison_job_config(
140178
jobs_dir: Path,
141179
job_name: str,
142180
) -> dict[str, Any]:
143-
"""Create one Harbor job containing every language-specific dataset."""
181+
"""Create one Harbor job with both languages interleaved task by task.
182+
183+
One single-task dataset per (task, language) is what interleaves them: Harbor
184+
resolves datasets in order and submits trials in that order, so one dataset
185+
per language would run every trial of the first language before the second
186+
and fold any drift in host load or model serving into the language contrast.
187+
The dataset path stays the language directory, so each trial's ``source`` is
188+
still the language and the report can attribute it.
189+
190+
Harbor's ``tasks`` list would express this more directly, but it seeds no
191+
entry in the job's metric registry (``Job._resolve_metrics`` walks
192+
``datasets`` only) while the live progress display indexes
193+
``_metrics[source][0]`` — a defaultdict miss that aborts the whole job with
194+
an IndexError partway through. Datasets keep that registry populated.
195+
"""
144196
config = copy.deepcopy(settings.harbor)
145197
config["job_name"] = job_name
146198
config["jobs_dir"] = str(jobs_dir.resolve())
@@ -149,8 +201,9 @@ def build_comparison_job_config(
149201
config["datasets"] = [
150202
{
151203
"path": str(task_dirs[_language_key(language)].resolve()),
152-
"task_names": list(settings.task_names),
204+
"task_names": [task_name],
153205
}
206+
for task_name in settings.task_names
154207
for language in settings.languages
155208
]
156209
return config
@@ -184,6 +237,7 @@ def run_comparison_experiment(
184237
*,
185238
exporter: Callable[..., list[Path]] = convert_all,
186239
agent_runner: Callable[[Path, Path], int] | None = None,
240+
version_resolver: Callable[[str], str] = resolve_agent_version,
187241
) -> int:
188242
"""Export both languages and run all trials in one parallel Harbor job."""
189243
config_path = config_path.resolve()
@@ -195,6 +249,10 @@ def run_comparison_experiment(
195249
raise ComparisonError(f"Agent runner does not exist: {runner}")
196250

197251
settings = validate_comparison_config(_read_yaml(config_path))
252+
# Fail before spending anything if the registry is unreachable; an unpinned
253+
# CLI is the confound this run exists to avoid. Pin explicitly in the config
254+
# to run without registry access.
255+
pin_agent_version(settings.agent, version_resolver)
198256
output_dir.mkdir(parents=True)
199257
jobs_dir = output_dir / "jobs"
200258
repo_root = runner.parent.parent
@@ -206,7 +264,11 @@ def run_comparison_experiment(
206264
"base_config": str(config_path),
207265
"base_config_sha256": _config_digest(config_path),
208266
"runner": str(runner),
209-
"agent": {"name": settings.agent.get("name"), "model_name": settings.agent.get("model_name")},
267+
"agent": {
268+
"name": settings.agent.get("name"),
269+
"model_name": settings.agent.get("model_name"),
270+
"version": settings.agent["kwargs"].get("version"),
271+
},
210272
"language_order": list(settings.languages),
211273
"parallelism": settings.parallelism,
212274
"task_names": settings.task_names,
@@ -467,6 +529,40 @@ def _delta(second: dict[str, Any], first: dict[str, Any]) -> dict[str, int | flo
467529
return result
468530

469531

532+
def _paired_difference(tasks: list[dict[str, Any]], first_key: str, second_key: str) -> dict[str, Any]:
533+
"""Summarize the per-task reward differences, second language minus first.
534+
535+
The interval is a Student's t interval over the task differences. It is
536+
descriptive: the tasks are a fixed selection, not a random sample of
537+
programming work, and each task contributes one mean per language.
538+
"""
539+
pairs = [
540+
(_numeric(task["results"][first_key]["reward_mean"]), _numeric(task["results"][second_key]["reward_mean"]))
541+
for task in tasks
542+
]
543+
deltas = [second - first for first, second in pairs if first is not None and second is not None]
544+
mean = statistics.fmean(deltas) if deltas else None
545+
sd = statistics.stdev(deltas) if len(deltas) > 1 else None
546+
degrees_of_freedom = len(deltas) - 1
547+
half_width = (
548+
(T_CRITICAL_95[degrees_of_freedom] if degrees_of_freedom < len(T_CRITICAL_95) else 1.96)
549+
* sd
550+
/ math.sqrt(len(deltas))
551+
if sd
552+
else None
553+
)
554+
return {
555+
"n": len(deltas),
556+
"mean": mean,
557+
"sd": sd,
558+
"ci_low": mean - half_width if half_width is not None else None,
559+
"ci_high": mean + half_width if half_width is not None else None,
560+
"wins": sum(delta > 0 for delta in deltas),
561+
"losses": sum(delta < 0 for delta in deltas),
562+
"ties": sum(delta == 0 for delta in deltas),
563+
}
564+
565+
470566
def build_comparison_report(experiment_dir: Path) -> dict[str, Any]:
471567
experiment_dir = experiment_dir.resolve()
472568
manifest = _read_json(experiment_dir / "manifest.json")
@@ -521,6 +617,7 @@ def build_comparison_report(experiment_dir: Path) -> dict[str, Any]:
521617
"delta": {"from": first_key, "to": second_key},
522618
"task_count": len(task_names),
523619
"tasks": tasks,
620+
"paired": _paired_difference(tasks, first_key, second_key),
524621
"totals": {
525622
"results": total_results,
526623
"delta": _delta(total_results[second_key], total_results[first_key]),
@@ -660,8 +757,24 @@ def render_markdown_report(report: dict[str, Any]) -> str:
660757
f"{_format_value(totals['delta'][field], kind, delta=True)} |"
661758
)
662759

760+
paired = report["paired"]
663761
lines.extend(
664762
[
763+
"",
764+
"## Paired difference",
765+
"",
766+
f"| Metric | {second_name} minus {first_name} |",
767+
"|---|---:|",
768+
f"| Paired tasks | {_format_value(paired['n'], 'count')} |",
769+
f"| Mean difference | {_format_value(paired['mean'], 'reward', delta=True)} |",
770+
f"| Standard deviation | {_format_value(paired['sd'], 'reward')} |",
771+
f"| Approximate 95% interval | [{_format_value(paired['ci_low'], 'reward', delta=True)}, "
772+
f"{_format_value(paired['ci_high'], 'reward', delta=True)}] |",
773+
f"| Task wins ({second_name} / {first_name} / tie) | {_format_value(paired['wins'], 'count')} / "
774+
f"{_format_value(paired['losses'], 'count')} / {_format_value(paired['ties'], 'count')} |",
775+
"",
776+
"The interval is descriptive, not inferential: the tasks are a fixed selection rather than a random "
777+
"sample of programming work, and each task contributes one mean per language.",
665778
"",
666779
"## Token breakdown by task",
667780
"",

0 commit comments

Comments
 (0)