Skip to content

fix(sandbox): stop with-skill builds poisoning no-skill rollouts - #938

Open
bingran-you wants to merge 1 commit into
mainfrom
bry/fix-no-skill-image-tag-leak
Open

fix(sandbox): stop with-skill builds poisoning no-skill rollouts#938
bingran-you wants to merge 1 commit into
mainfrom
bry/fix-no-skill-image-tag-leak

Conversation

@bingran-you

Copy link
Copy Markdown
Collaborator

A with-skill build and a no-skill build of the same task share one Docker image tag. Whichever builds last decides what both start from — so a no-skill rollout can run inside an image that already contains /skills, and the agent reads the mentor guidance the condition exists to withhold.

The bug

--skills-dir rewrites the task Dockerfile in place (_inject_skills_into_dockerfile):

COPY _deps/skills /skills/
RUN mkdir -p /home/agent/.opencode && ln -sf /skills /home/agent/.opencode/skills

But the tag came from the task name alone (docker.py):

main_image_name=_sanitize_docker_image_name(f"bf__{environment_name}")

Both modes therefore resolve to bf__<task>:latest.

Evidence

Same task, same host:

Check Result
Clean build of the task's own Dockerfile SKILL_MD_COUNT=0
Shared tag before any with-skill run COUNT=0, /skills empty
Shared tag after a with-skill run COUNT=4 — all four skills at /skills/
No-skill rollout in isolation container COUNT=0
No-skill rollout alongside a with-skill one container COUNT=4

The affected rollout was resolved correctly and still leaked:

skill_mode=no-skill  include_task_skills=False  skills_sandbox_dir=None  effective_skills_dir=None

Both deployment paths behaved: _activate_step_skills returned early (skills_dir: null) and deploy_skills no-opped. The skills were already in the image, put there by a different rollout.

Its trajectory then shows the agent loading ion-shuttling-mentor, surface-ion-trap-bem and invariant-based-transport from /home/agent/.opencode/skills/, and solving in 31 tool calls a task whose uncontaminated no-skill condition had previously failed.

Why it went unnoticed

total_skill_invocations reports 0 whether or not the agent reads a SKILL.md off disk — it was 0 for the with-skill runs too. A contaminated run reports an ordinary pass and no counter disagrees.

This silently corrupts the primary no-skill capability metric, and a task author cannot prevent it — the task's own Dockerfile is clean.

Note this is not the familiar "task bakes its own skills via COPY skills …" mistake. That one is the task's to fix; this one isn't.

The fix

  1. Tag by build context. _build_context_fingerprint() hashes the Dockerfile bytes plus the staged _deps/skills layout, and the tag becomes bf__<task>__<fingerprint>. Builds whose contents differ can no longer share a tag. Hashing what is actually built also means an unrelated Dockerfile edit correctly yields a new image.
  2. Verify rather than assume. assert_no_skill_isolation() runs after deploy_skills on the agent path when the recorded mode is no-skill. It probes the agent's own skill_paths (following symlinks) plus /skills, and raises instead of letting a contaminated run be recorded. Only absolute paths ending in /SKILL.md count, so shell noise can't produce a false positive.

Belt and braces on purpose: (1) removes the cause, (2) means a future regression fails loudly instead of quietly returning a wrong number.

Tests

tests/test_no_skill_image_isolation.py — 7 cases:

  • fingerprint stable for identical contexts
  • skill injection changes the fingerprint (the core regression)
  • changing the skill payload changes the fingerprint
  • isolation check passes on a clean sandbox, and probes both the agent path and /skills
  • isolation check raises when a SKILL.md is reachable
  • handles the oracle path (no agent config)
  • ignores non-skill stdout

Verification

  • 577 related tests pass (-k "docker or skill or install or image"); ruff check clean and formatted.
  • tests/test_trial_install_agent_timeout.py initially failed against an early version of the guard — its env double returns canned stdout for every exec. That surfaced a real over-permissiveness in my check rather than a test problem, so the guard now matches only SKILL.md paths; that suite is green.

Reviewer notes

  • The tag gains a 12-hex suffix. Anything pinning the literal bf__<task>:latest externally would need updating — I found no such assumption in-tree, but it's the change worth a second pair of eyes.
  • The guard is agent-path only. The oracle path is supported by the function but not yet wired, since the oracle legitimately runs with skills available.

A with-skill build rewrites the task Dockerfile in place: --skills-dir
appends `COPY _deps/skills /skills/` plus a symlink into every agent skill
path. The image tag, though, was derived from the task name alone
(`bf__<task>`), so the with-skill and no-skill builds of one task shared a
single tag. Whichever built last decided what both started from.

A no-skill rollout could therefore run inside an image that already contained
/skills and the agent-side symlinks. benchflow resolved the rollout correctly
(skill_mode=no-skill, include_task_skills=False, skills_sandbox_dir=None) and
skipped every deployment path — and the agent still read mentor SKILL.md files
off disk, because they were baked into the image by a different rollout.

Nothing caught it. total_skill_invocations reads 0 whether or not the agent
loads a skill from disk, so a contaminated run reports a clean pass. This is
silent corruption of the primary no-skill capability metric, and a task author
cannot prevent it: the task's own Dockerfile is clean.

Observed on a FrontierPhysics task: a no-skill rollout read the full mentor
recipe from /home/agent/.opencode/skills/ and solved in 31 tool calls what the
uncontaminated condition had previously failed.

Fix the tag, then verify the condition:

- Derive the image tag from a fingerprint of the build context (Dockerfile
  bytes plus the staged _deps/skills layout), so builds whose contents differ
  cannot share a tag.
- Add assert_no_skill_isolation(), called after deploy_skills on the agent
  path when the recorded mode is no-skill. It probes the agent's own discovery
  paths and /skills, and fails the rollout rather than let a contaminated run
  be recorded as a result.

Only absolute paths ending in /SKILL.md count as findings, so shell noise on
the probe's stdout cannot produce a false positive.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@bingran-you
bingran-you temporarily deployed to pypi-internal-preview July 28, 2026 22:50 — with GitHub Actions Inactive

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1b04468e3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1156 to +1158
await self._planes.assert_no_skill_isolation(
self._env, self._agent_cfg, cfg.sandbox_user
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve compatibility for injected rollout planes

In no-skill rollouts that supply a custom plane bundle, this unconditional call now raises AttributeError unless every existing implementation adds the new method. This already breaks the synthetic _FakePlanes used by tests/test_task_runtime_primitive.py, and the same failure affects external BYO plane implementations accepted by TaskRuntimeConfig and the TRL integration. Provide a kernel fallback or update all supported implementations before requiring this method.

Useful? React with 👍 / 👎.

Comment on lines +96 to +99
for path in sorted(skills_root.rglob("*")):
hasher.update(str(path.relative_to(skills_root)).encode())
if path.is_file():
hasher.update(str(path.stat().st_size).encode())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Hash staged skill contents, not only their sizes

When two with-skill rollouts use the same task and skill paths but different same-length SKILL.md contents, this fingerprint is identical because it hashes only each path and byte count. start() serializes the builds but releases the image lock before compose up, so the second build can overwrite the shared tag before the first container starts, silently giving that rollout the wrong mentor content. Hash the file bytes so every distinct staged payload receives a distinct tag.

Useful? React with 👍 / 👎.

Comment on lines +385 to +387
if agent_cfg is not None:
for skill_path in agent_cfg.skill_paths:
candidates.append(skill_path.replace("$HOME", home))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Expand workspace-based skill discovery paths

For agents with $WORKSPACE discovery paths, such as OpenHands and OpenClaw, this only expands $HOME; shlex.quote() then makes $WORKSPACE/.agents/skills a literal relative path. A no-skill sandbox containing guidance only at the actual workspace discovery path therefore passes this guard and can still produce a contaminated result. Pass the agent cwd and expand $WORKSPACE exactly as _link_skill_paths() does.

AGENTS.md reference: AGENTS.md:L30-L30

Useful? React with 👍 / 👎.

Comment on lines +46 to +47
def test_skill_injection_changes_the_fingerprint(tmp_path):
"""The core regression: the two builds must not share an image tag."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Name the guarded commit in the regression-test docstring

This test explicitly identifies itself as guarding the core regression, but its docstring names neither a PR nor a commit. Add the identifier for the change it guards so future maintainers can trace the intended behavior as required by the repository convention.

AGENTS.md reference: AGENTS.md:L16-L17

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant