Skip to content

fix(authz): recheck policy before sandbox reuse - #5006

Merged
WillemJiang merged 9 commits into
bytedance:mainfrom
PeaceMaker-best:fix/authz-recheck-reused-sandbox
Aug 30, 2026
Merged

fix(authz): recheck policy before sandbox reuse#5006
WillemJiang merged 9 commits into
bytedance:mainfrom
PeaceMaker-best:fix/authz-recheck-reused-sandbox

Conversation

@PeaceMaker-best

Copy link
Copy Markdown
Contributor

Related to #4063 and follow-up to #4911.

Why

Sandbox execution authorization was checked only when a sandbox was first acquired. If a thread already held a persisted sandbox id, the reuse path returned before authorization ran, so a live role or policy revocation did not take effect until that sandbox was released.

The authorization RFC defines sandbox:execute as a runtime execution decision. A cached sandbox should not become a capability that outlives the current policy.

What changed

  • Sync and async sandbox tool initialization now authorize before either reusing a persisted sandbox or acquiring a new one.
  • Denied reuse stops before provider lookup/acquisition, preserving the existing friendly SandboxAuthorizationError path.
  • Regression tests cover both sync and async persisted-sandbox reuse after access is revoked.
  • The sandbox invariant documentation now records the per-use authorization boundary.

Surface area

  • Frontend UI — page / component / setting / interaction under frontend/
  • Backend API — endpoint / SSE event / request-response shape under backend/app
  • Agents / LangGraph — agent node, graph wiring, langgraph.json, or prompt change
  • Sandbox — docker/ or sandboxed execution
  • Skills — change under skills/
  • Dependencies — new/upgraded entry in backend/pyproject.toml or frontend/package.json
  • Default behavior change — a revoked sandbox:execute grant now applies to the next sandbox-backed tool call
  • Docs / tests / CI only — no runtime behavior change

Screenshots / Recording

Not applicable; backend authorization behavior only.

Bug fix verification

  • Test path that reproduces the bug: backend/tests/test_sandbox_authorization.py
  • Did it go red on main and green on this branch? yes — both new reuse tests failed with DID NOT RAISE before the fix.
  • The tests also assert that provider get/acquire is never reached after denial.

Validation

  • uv run pytest -q tests/test_sandbox_authorization.py — 25 passed
  • uv run ruff check packages/harness/deerflow/sandbox/tools.py tests/test_sandbox_authorization.py
  • uv run ruff format --check packages/harness/deerflow/sandbox/tools.py tests/test_sandbox_authorization.py
  • Additional Windows sandbox sweep: 655 passed, 28 skipped; 6 existing platform-specific path separator/symlink assertions failed outside the changed authorization path.

AI assistance

Tool(s) used: Codex

How you used it: Inspected the authorization and sandbox lifecycle, wrote red regression tests, implemented the minimal gate relocation, reviewed the complete diff, and ran targeted and broader validation.

  • I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

@github-actions github-actions Bot added area:docs Documentation and Markdown only area:sandbox Sandboxed execution and docker/ risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/S PR changes 20-100 lines labels Aug 25, 2026

@willem-bd willem-bd 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.

[P2] Run one asynchronous authorization check per async sandbox tool invocation

ensure_sandbox_initialized_async() performs the new authorization check, then _run_sync_tool_after_async_sandbox_init() executes the synchronous tool body. Every synchronous body calls ensure_sandbox_initialized() again, and because this PR moves the gate before sandbox reuse, that second call authorizes again. I reproduced two authorization-provider calls for one async tool invocation.

Besides duplicating audit and rate-limit effects, the first pass calls synchronous authorize() on the event loop even though the pluggable provider contract exposes aauthorize(). Please make the async tool path await one async sandbox authorization check and avoid repeating it inside the offloaded synchronous body, with coverage asserting exactly one decision per invocation.

Focused validation at 03b0b06d: 31 tests passed; Ruff lint and format checks passed.

@PeaceMaker-best
PeaceMaker-best force-pushed the fix/authz-recheck-reused-sandbox branch from 03b0b06 to 9032108 Compare August 25, 2026 07:42
@github-actions github-actions Bot added size/M PR changes 100-300 lines and removed size/S PR changes 20-100 lines labels Aug 25, 2026
@PeaceMaker-best

Copy link
Copy Markdown
Contributor Author

@willem-bd Thanks for catching this — fixed in 9032108a.

The async tool path now:

  • awaits AuthorizationProvider.aauthorize() in ensure_sandbox_initialized_async();
  • uses a task-local ContextVar handoff into asyncio.to_thread() so the synchronous tool body does not authorize again;
  • resets that handoff in finally, keeping it scoped to one invocation.

I also switched the async eager middleware path to aauthorize() and added a regression test that invokes async ls and asserts aauthorize() is awaited exactly once while authorize() is never called.

Validation: Ruff lint/format passed; 57 focused authorization, initialization, middleware, and blocking-I/O tests passed. A broader sandbox-tool run had 210 passes and the same 6 unrelated Windows path/symlink baseline failures.

@WillemJiang WillemJiang added this to the 2.1.0 milestone Aug 25, 2026
@PeaceMaker-best
PeaceMaker-best force-pushed the fix/authz-recheck-reused-sandbox branch from 9032108 to dfbded7 Compare August 25, 2026 13:32

@willem-bd willem-bd 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.

One remaining authorization-path issue found at the current head. Focused sandbox and authorization tests pass, but the composed middleware path still duplicates provider decisions.

Comment thread backend/packages/harness/deerflow/sandbox/tools.py Outdated

@willem-bd willem-bd 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.

I found two blocking issues at current head 9ec833e5:

  1. [P2] Scope one sandbox authorization decision across the complete middleware invocation. In backend/packages/harness/deerflow/sandbox/tools.py, _ASYNC_SANDBOX_AUTHORIZATION_CHECKED only covers the synchronous tool body dispatched by _run_sync_tool_after_async_sandbox_init. The default ReadBeforeWriteMiddleware re-enters ensure_sandbox_initialized() after that scope ends while stamping a read mark, and before the scope starts while checking writes. I reproduced two provider decisions for one composed read_file call: sync performs authorize() twice; async performs one aauthorize() plus a second sync authorize(). This duplicates provider audit/rate-limit effects and can produce conflicting verdicts from a stateful provider. Please scope the marker across the full middleware tool invocation, while ensuring SandboxAuthorizationError cannot be swallowed by the read-before-write generic fail-open paths, and add composed read/write coverage.

  2. [P2] Keep config and provider resolution off the async event loop. ensure_sandbox_initialized_async() evaluates safe_app_config() synchronously, and authorize_sandbox_execution_async() then calls _resolve_authorization_inputs() synchronously before its first await. get_app_config() stats and SHA-256 hashes the complete config file; provider resolution may dynamically import and construct a custom provider. Because this PR moves the check before reuse, that blocking work now runs on every reused async sandbox tool call. The strict blocking-I/O job does not expose it because safe_app_config() catches the detector BlockingError as a generic exception and returns None. Please offload the synchronous resolution work or pass a safely pre-resolved snapshot/provider, and add a strict blocking-I/O regression test.

Validation at the reviewed sandbox code: 108 focused authorization/middleware tests passed, and Ruff lint/format passed. The latest merge only incorporates main changes outside this sandbox diff, so both reproductions remain applicable.

@PeaceMaker-best

Copy link
Copy Markdown
Contributor Author

Addressed both blocking issues in d7687559:

  1. The sandbox authorization scope now spans the complete composed ReadBeforeWriteMiddleware invocation, including the pre-write gate, tool body, and post-read mark. SandboxAuthorizationError explicitly bypasses the generic fail-open handlers and is returned as the normal error ToolMessage.
  2. Async config loading and authorization input/provider resolution are now offloaded from the event loop before awaiting aauthorize().

Regression coverage now exercises composed sync/async read_file, write_file, and str_replace calls and asserts exactly one provider decision per invocation, plus denial propagation and a strict blocking-I/O test for reused async sandboxes.

Validation: 139 focused authorization/middleware tests passed; repository-wide Ruff lint and format checks passed.

Please take another look when convenient.

@github-actions github-actions Bot added area:agents Agents, subagents, graph wiring, prompts, langgraph.json needs-validation Touches front/back contract surface; needs real-path validation size/L PR changes 300-700 lines and removed size/M PR changes 100-300 lines labels Aug 27, 2026

@willem-bd willem-bd 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.

Reviewed at head d768755. The focused authorization and middleware suite passes (89 tests), Ruff checks pass, and all GitHub checks are green. I found one remaining custom-provider compatibility issue; details are inline.

file load performed by :func:`safe_app_config_async`.
"""
context_snapshot = dict(context)
inputs = await asyncio.to_thread(

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.

[P2] Construct async providers on the event loop

This to_thread call includes resolve_authorization_provider(), which imports and constructs the configured custom provider. A structurally valid async provider may initialize a loop-affine client in its constructor; constructing it in this worker has no running event loop. I reproduced that provider failing before aauthorize() is reached: fail_closed denies every async sandbox call, while fail_open proceeds without any provider decision. The same provider can be constructed and used normally by the existing event-loop authorization paths. Please keep config hashing and other blocking discovery off the loop without moving provider construction to an unsupported thread (or reuse an appropriately resolved provider), and add a regression with a loop-affine async provider.

@github-actions github-actions Bot added size/XL PR changes 700+ lines and removed size/L PR changes 300-700 lines labels Aug 28, 2026
Comment thread backend/tests/blocking_io/test_sandbox_authorization.py Outdated
@WillemJiang
WillemJiang merged commit 137a3cb into bytedance:main Aug 30, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:agents Agents, subagents, graph wiring, prompts, langgraph.json area:docs Documentation and Markdown only area:sandbox Sandboxed execution and docker/ needs-validation Touches front/back contract surface; needs real-path validation risk:high High risk: backend API, agents, sandbox, auth, deps, CI size/XL PR changes 700+ lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants