Skip to content

feat: add private research controls on top of URL denylist - #1112

Open
ElegantLin wants to merge 4 commits into
mainfrom
feat/research-url-block-policy
Open

feat: add private research controls on top of URL denylist#1112
ElegantLin wants to merge 4 commits into
mainfrom
feat/research-url-block-policy

Conversation

@ElegantLin

@ElegantLin ElegantLin commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Extends the generic URL/host denylist merged in #1113 with the private,
run-scoped research controls needed to address the discovery/privacy part of
benchflow-ai/FrontierPhysics#365. This PR is an additive research-benchmark
layer, not a competing replacement for network_mode: denylist.

  • add bench eval run --research-policy /secure/policy.yaml plus SDK, YAML-config, matrix, and worker-sharding propagation
  • resolve a private per-task deny policy for URLs, URL prefixes, hosts, case-insensitive terms, and exact content SHA-256 values
  • inject one benchflow-research MCP server (web_search, web_fetch, web_download) across ACP and native-MCP-config harnesses
  • run the policy engine as a root-owned loopback gateway and block all other sandbox-user IPv4/IPv6 egress before the agent process starts
  • disable harness-native web tools and strip provider-side search/URL-context controls at the loopback LiteLLM proxy to close confused-deputy bypasses
  • support native Claude subscription auth through a fixed-destination loopback relay to api.anthropic.com; reject Anthropic server-side web tools and remote MCP requests before forwarding
  • honor explicit OpenAI-compatible endpoints for bare/future model IDs and propagate Codex reasoning effort into the LiteLLM deployment route
  • use DuckDuckGo Lite for the default search backend and parse its result links; keep the sandbox-local LiteLLM bootstrap portable on Docker Desktop ARM64
  • persist only policy hash/count/enforcement metadata; redact the private path from durable worker/run artifacts
  • fail closed for missing task entries, root agents, external pre-started sandboxes, and non-Docker providers

Relationship to #1113

#1113 provides a task-declared network denylist for ordinary HTTP clients: it
prevents the agent from retrieving configured URL prefixes or hosts while
leaving other network access available.

This PR covers the stricter research-evaluation case where the target list must
remain private and the agent should not discover the target paper while
searching:

  • the policy is supplied outside the task package and is unreadable by the agent
  • search results are filtered by blocked URLs and case-insensitive terms before the agent sees them
  • fetched/downloaded content can also be rejected by exact SHA-256, covering known byte-identical mirrors
  • native and provider-side web tools are disabled or rejected so they cannot bypass the filtered research gateway
  • Claude subscription traffic uses a fixed-destination provider relay, while all other direct sandbox-user egress remains blocked

When --research-policy is absent, #1113's normal network_mode: denylist
behavior is unchanged. When it is present, the stricter private research path
takes precedence instead of starting both proxy implementations.

Policy boundary

This guarantees that the sandbox user cannot directly retrieve listed resources or bypass the gateway with curl/raw sockets. It cannot erase knowledge already present in model pretraining or hide unlisted mirrors/citations; operators can add blocked terms, content hashes, and known mirror URLs when discovery leakage matters.

Validation

  • uv run pytest -q after rebasing onto f396c3556146 passed, 86 skipped, 8 deselected in 12m18s
  • uv run pytest tests/test_research_policy.py -m integration -q after the rebase — 1 passed, 32 deselected in 41.80s
  • the post-rebase cross-feature suite covering research policy, the new upstream denylist, hosted-search controls, LiteLLM routing, and smoke wiring — 307 passed, 1 deselected
  • uv run pytest tests/test_source_adapters.py -q — 21 passed; the previously observed full-suite startup timeout also passed in four isolated repetitions and in the final full suite
  • targeted ruff format --check, targeted ruff check, and full uv run ty check — passed
  • live claude-agent-acp / claude-sonnet-4-6 / Docker / subscription-auth rollout — reward 1.0; blocked MCP URL rejected, allowed MCP URL fetched, direct curl failed with exit 7, and an Anthropic server-side web-search request was rejected with HTTP 403
  • live claude-agent-acp / claude-haiku-4-5-20251001 / Docker rollout exercised all three MCP tools — reward 1.0; web_fetch and web_download succeeded for an allowed URL, a blocked fetch was rejected, the download was verified, and direct curl failed with exit 7
  • a second live Claude Haiku rollout after switching to DuckDuckGo Lite returned 10 real web_search results — reward 1.0
  • artifact scans for both Claude live runs found zero secret or private-policy leakage matches
  • live codex-acp was launched with recorded model: gpt-5.6-sol and reasoning_effort: xhigh; sandbox-local LiteLLM startup, the UID firewall, the explicit provider route, and the outbound provider request were all exercised, and artifact scanning found zero API-key matches

The Docker canary also proves the private root policy is unreadable by the agent and deterministically checks that a correctly formed Anthropic provider-side web-search request returns HTTP 403.

The Codex live run is not counted as a successful generation: the configured provider authenticated but returned model_not_found because no account in that provider group supports gpt-5.6-sol. The requested model and xhigh effort were kept unchanged rather than silently substituting a different model. BenchFlow now records those two fields separately and routes xhigh into the LiteLLM deployment configuration.


Devin Review

@ElegantLin
ElegantLin deployed to pypi-internal-preview September 8, 2026 22:08 — with GitHub Actions Active
@ElegantLin
ElegantLin deployed to pypi-internal-preview September 8, 2026 22:09 — with GitHub Actions Active

@devin-ai-integration devin-ai-integration 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.

Devin Review found 6 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

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.

🟥 Root setup bypasses research filtering

Task setup commands run as root before the user firewall activates. They can fetch blocked content into the workspace, bypassing all research-policy checks.

(Refers to this code)

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +506 to +508
# Private operator-side policy file. Its contents and path are never copied
# into public rollout/worker artifacts.
research_policy_path: str | None = None

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.

🟡 Resumes mix research policies

Resuming with a changed or removed policy leaves completed tasks skipped. _check_resume_mismatch ignores research_policy_path, so final scores combine different access rules.

Prompt for agents
Add research-policy compatibility to resume validation in src/benchflow/evaluation.py. _check_resume_mismatch currently compares only the agent and loop configuration, while _get_completed_tasks skips completed rollouts unconditionally. Compare the current per-task resolved policy state and SHA-256 against each completed rollout's config.json metadata. Reject resumes that add, remove, or change a policy, or ensure affected tasks are rerun. Preserve private policy paths and rule values in all errors and artifacts.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +959 to +962
if cfg.research_policy_path is not None:
self._research_policy = load_research_policy(
cfg.research_policy_path, task_id=cfg.task_path.name
)

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.

🟡 Missing policies allow partial batches

An uncovered task fails only during its own setup. Concurrent covered tasks keep running, so policy validation produces a partial evaluation instead of stopping the batch.

Prompt for agents
Preflight research policies against the complete selected task set before Evaluation schedules any rollouts. Load and validate each selected task entry once, abort the batch before execution if any entry is missing or empty, and pass the resolved per-task policy or validated policy state into rollout setup without exposing private values in worker or run artifacts. Keep programmatic single-rollout validation as defense in depth.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +298 to +301
"content": (
response.body.decode("utf-8", errors="replace")
if textual
else base64.b64encode(response.body).decode("ascii")

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.

🟡 Text downloads corrupt original bytes

A response labeled textual is decoded with replacement by _result_payload. web_download re-encodes that string, corrupting non-UTF-8 files instead of preserving server bytes.

Prompt for agents
Preserve exact response bytes for web_download in src/benchflow/sandbox/_research_gateway_runtime.py. The shared _result_payload currently converts text bodies to a UTF-8 string, and _mcp_call converts that string back to bytes. Return or request base64 for downloads regardless of Content-Type while retaining readable text output for web_fetch. Add coverage using non-UTF-8 text bytes and verify the saved file matches byte-for-byte.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +963 to +964
if cfg.primary_agent != "oracle" and cfg.sandbox_user is None:
raise ValueError("research policy requires a non-root sandbox_user")

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.

🟥 Oracle-first scenes bypass user isolation

An oracle primary bypasses the non-root validation. A later non-oracle scene role then runs as root without firewall enforcement, allowing unrestricted network access.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +498 to +499
server = ThreadingHTTPServer(("127.0.0.1", port), GatewayHandler)
server.serve_forever()

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.

🟨 Gateway requests create unbounded threads

Each loopback request creates a new ThreadingHTTPServer thread without a concurrency cap. An agent can exhaust container memory or descriptors with parallel requests.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@ElegantLin
ElegantLin deployed to pypi-internal-preview September 8, 2026 23:10 — with GitHub Actions Active
@ElegantLin

Copy link
Copy Markdown
Contributor Author

Claude live-validation follow-up (b6bdc857):

The first real claude-agent-acp + subscription-auth + research-policy rollout exposed a genuine gap: native Claude auth skips LiteLLM, so the UID firewall had no loopback provider endpoint and failed closed before the prompt.

The follow-up routes native Anthropic traffic through a root-owned, fixed-destination loopback relay to api.anthropic.com. The relay is not a general proxy and rejects provider-side web-search/web-fetch tools, remote MCP requests, compressed/ambiguous message bodies, and duplicate JSON keys before forwarding.

Validation on the pushed tree:

  • Claude Sonnet 4.6 live Docker rollout: reward 1.0, 6 tool calls; blocked MCP URL rejected, allowed MCP fetch succeeded, direct curl failed with exit 7, and an Anthropic web_search_20250305 request returned HTTP 403.
  • Claude Haiku 4.5 live Docker rollout: reward 1.0, 6 tool calls, no rollout/verifier error.
  • Deterministic Docker canary: 1 passed in 30.05s, including a correctly formed provider-side web-search bypass assertion.
  • Focused regression suite: 59 passed, 1 deselected; Ruff and ty check pass.
  • The repository's existing Claude live smoke also passed when rerun alone (1 passed in 113.56s).

One Sonnet attempt hit a transient ACP -32603 during initialization and succeeded on the automatic retry; no policy assertion was skipped.

@ElegantLin
ElegantLin force-pushed the feat/research-url-block-policy branch from c683832 to 47cf868 Compare September 9, 2026 02:02
@ElegantLin
ElegantLin deployed to pypi-internal-preview September 9, 2026 02:02 — with GitHub Actions Active
@ElegantLin ElegantLin changed the title feat: add private filtered web research policies feat: add private research controls on top of URL denylist Sep 9, 2026
@ElegantLin

Copy link
Copy Markdown
Contributor Author

Positioning update after #1113 merged:

The PR title and description now state this relationship explicitly. Head 47cf8680 remains cleanly mergeable and all current checks pass; the documentation-only PR metadata update did not change the tested code.

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