Skip to content

feat(authz): enforce sandbox:execute authorization at sandbox acquisition (#4063 Phase 3) - #4911

Merged
WillemJiang merged 1 commit into
bytedance:mainfrom
hata33:feat/authz-phase3-sandbox
Aug 24, 2026
Merged

feat(authz): enforce sandbox:execute authorization at sandbox acquisition (#4063 Phase 3)#4911
WillemJiang merged 1 commit into
bytedance:mainfrom
hata33:feat/authz-phase3-sandbox

Conversation

@hata33

@hata33 hata33 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Part of #4063

Why

Phase 3 Models (#4540) and Skills (#4541) connected the AuthorizationProvider to model and skill access, but sandbox execution remained gated only by config presence (feat.sandbox is not False) — any authenticated user got full sandbox execution (bash, file I/O). This PR adds sandbox:execute enforcement so an RBAC policy like sandbox: {allow: false} actually takes effect. It is the last of three Phase 3 resource-type PRs (Models → Skills → Sandbox).

Sandbox differs from models/skills: it is an execution environment, not a named catalog — multiple tools (bash, read_file, write_file, glob, grep, …) depend on it. Instead of maintaining a sandbox-tool-name set in middleware (which would need updating every time a sandbox tool is added), the gate lives at the single sandbox-acquisition entry point, so it cannot be bypassed regardless of which tool triggers it.

What changed

Gate helper — authorize_sandbox_execution() (authz/sandbox_authz.py):

  • Checks authorize("sandbox", "execute", target="*") — a binary judgment ("can this role use the sandbox at all"). RBAC allow: "*"/true permits, allow: []/false denies; no policy for a role → unrestricted (consistent with every other resource type).
  • No-op when authorization.enabled is false; provider decision errors follow fail_closed (deny) / fail_open (allow); mock-safe getattr guards mirror filter_available_skills_by_authorization.

Deny behavior — SandboxAuthorizationError(SandboxError) (sandbox/exceptions.py):

  • Carries the denied role; propagates through tool execution and surfaces as a friendly error ToolMessage ("sandbox execution is not permitted for your role"), not a crash (RFC §9).

Gates at every sandbox-acquisition entry point (verified by grepping all provider.acquire / acquire_async call sites):

Entry point Gate
ensure_sandbox_initialized + async (lazy path — all sandbox tools) authorize_sandbox_execution before provider.acquire
SandboxMiddleware.before_agent / abefore_agent (eager path, lazy_init=False) same gate before _acquire_sandbox
uploads router (upload → sandbox sync) authorize_sandbox_for_request(); deny skips the sync — the upload itself still succeeds (a sandbox-denied agent cannot consume the files anyway)
artifacts router (artifact edit → sandbox sync) same wrapper; deny skips the sync, host-side update still completes
Feishu / DingTalk channel file-download sync deliberately not gated — the authorization identity is not resolvable there (owner-user binding is established at run start, not at file download); synced files are unconsumable by a denied agent regardless. Recorded in the implementation notes as a follow-up.

authorize_sandbox_for_request() (gateway/authz.py) builds the Principal from request.state.user via a new shared _route_authz_context helper (also adopted by resolve_model_authorization), including the INTERNAL_SYSTEM_ROLE → None pop. Provider-resolution failures degrade per fail_closed instead of 500-ing the route.

authorization.enabled: false is a complete no-op on every gate. The RBAC provider already maps "sandbox" → "sandbox" (_RESOURCE_POLICY_KEYS), so no schema change is needed.

Authorization RFC continuity confirmation

  • Read docs/plans/2026-07-10-pluggable-authorization-rfc.md.
  • Read docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md.
  • Checked all prior-phase decisions and deferred items.
  • Updated the implementation notes with this PR's decisions and deferred items (gate placement, target="*" semantics, auxiliary-path decisions, deferred channel paths).

Surface area

  • Frontend UI
  • Backend API — uploads/artifacts deny skips the sandbox sync (still 200); provider-resolution failures degrade per fail_closed instead of 500.
  • Agents / LangGraphensure_sandbox_initialized gates all sandbox tools at runtime.
  • SandboxSandboxMiddleware eager path + acquisition entry gated.
  • Skills
  • Dependencies
  • Default behavior change — only when authorization.enabled: true (opt-in). Default false = identical to today.
  • Docs / tests / CI only — config.example.yaml (sandbox: policy examples), implementation notes updated.

Validation

# Sandbox authorization + full authz regression + affected routers
cd backend && PYTHONPATH=. uv run pytest \
  tests/test_sandbox_authorization.py \
  tests/test_sandbox_middleware.py \
  tests/test_ensure_sandbox_initialized.py \
  tests/test_uploads_router.py \
  tests/test_uploads_manager.py \
  tests/test_artifacts_router.py \
  tests/test_models_authorization.py \
  tests/test_authorization_route_permissions.py \
  tests/test_authorization_enforcement.py \
  tests/test_authorization_principal.py \
  tests/test_authorization_provider.py \
  tests/test_authorization_runtime.py \
  tests/test_authorization_tool_filter.py \
  tests/test_rbac_authorization_provider.py \
  tests/test_auth_middleware.py \
  -q
# -> 430 passed

# Lint + format
cd backend && uv run ruff check . && uv run ruff format --check .

Mutation-tested: reverting the tools.py gate or the defensive guards makes the corresponding regression tests fail.

Related

@github-actions github-actions Bot added area:backend Gateway / runtime / core backend under backend/ 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 labels Aug 20, 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 069aeee. Overall this looks well put together — gating at the single sandbox-acquisition entry point (rather than a tool-name set in middleware) is the right call, the uploads/artifacts deny-skips-sync behavior is handled consistently (including the sandbox is not None guard before the sync loop), and the test coverage hits allow/deny/no-policy/fail-closed/fail-open plus the router integration paths. Three notes below: the eager-path deny won't get the RFC §9 friendly-message treatment, provider-resolution failures invert fail-open semantics in the harness paths, and a question about the no-user branch in the routers.

return super().before_agent(state, runtime)
# Phase 3: enforce sandbox:execute authorization before acquiring
# (eager path). The lazy path is gated inside ensure_sandbox_initialized.
authorize_sandbox_execution(

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.

Suggestion: on this eager path, a SandboxAuthorizationError raised from before_agent is not inside a tool call, so ToolErrorHandlingMiddleware never converts it into a friendly ToolMessage — it will surface as a run-level graph error instead of the RFC §9 behavior the PR description describes (which only holds for the lazy path). The Gateway factory only constructs SandboxMiddleware(lazy_init=True) today (agents/factory.py:232), so this is currently unreachable, but if lazy_init=False is ever used with authorization enabled, a denied role crashes the whole run rather than getting the friendly per-turn message. Consider catching the error here and starting the agent without a sandbox (the lazy gate in ensure_sandbox_initialized would then deny per-tool with the friendly message), or noting the limitation explicitly so the two paths' deny semantics are documented as intentional.

if authz_config is None or getattr(authz_config, "enabled", None) is not True:
return

provider = resolve_authorization_provider(authz_config)

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.

resolve_authorization_provider runs outside the try, so a misconfigured provider (bad class path, constructor failure → ValueError) propagates as a raw exception rather than a SandboxAuthorizationError. The Gateway wrapper (authorize_sandbox_for_request) handles that explicitly and degrades per fail_closed, but in the harness paths (middleware and ensure_sandbox_initialized) the outcome inverts fail_open: a provider resolution error effectively denies sandbox (raw exception through the tool) even though every other provider error under fail_open is meant to allow. Consider moving the resolution inside the try / wrapping it so resolution failures follow the same fail_closed/fail_open decision as authorize() errors.

Comment thread backend/app/gateway/routers/uploads.py Outdated
# thread uploads dir; the agent cannot consume them via sandbox anyway).
try:
user = await get_optional_user_from_request(request)
if user is not 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.

Nit/question: when get_optional_user_from_request returns None, the sync proceeds with no authorization check at all (same in artifacts.py). Is that combination reachable — e.g. authentication middleware disabled while authorization.enabled: true? If yes, this is a fail-open hole in the sync path; if it can't happen in practice, a one-line comment saying why would help future readers. (Same applies to the artifacts router's copy of this block.)

@hata33
hata33 force-pushed the feat/authz-phase3-sandbox branch 2 times, most recently from 17a509d to c6a17da Compare August 20, 2026 10:16
@hata33

hata33 commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

All three notes addressed in c6a17da9 — thank you for the thorough review.

1. Eager-path deny (run-level error) — fixed by deferring to the lazy gate. before_agent / abefore_agent now catch SandboxAuthorizationError, log, and return None (no sandbox assigned) instead of raising. The run starts without a sandbox, and the first sandbox-touching tool call hits the lazy gate inside ensure_sandbox_initialized, which denies per-tool with the RFC §9 friendly ToolMessage — both paths now produce identical deny semantics. Added test_eager_before_agent_deny_skips_acquisition asserting the middleware returns None and _acquire_sandbox is never touched on deny.

2. Provider-resolution failures inverting fail-open — fixed. resolve_authorization_provider now runs inside its own fail-closed/fail-open decision in authorize_sandbox_execution: a resolution error under fail_closed raises SandboxAuthorizationError; under fail_open it returns (allow), matching the semantics of authorize() errors. The Gateway wrapper keeps its explicit degradation for the same reason (no 500s on the route), now effectively a second line of defense. Added test_authorize_sandbox_resolution_error_fail_open_allows / ..._fail_closed_denies — mutation-tested: reverting the wrap makes the fail-open test fail with the raw ValueError.

3. user is None reachability — verified unreachable, documented. The combination can't occur today: anonymous requests are rejected by @require_permission (401 via empty permissions) before reaching the sync code, and auth-disabled mode stamps a request.state.user with AUTH_SOURCE_AUTH_DISABLED (which get_current_user_from_request accepts), so get_optional_user_from_request returns a user there too. Added the one-line comment in both routers explaining why, noting the residual semantics if it ever becomes reachable (fail-open, same as the models routes' anonymous bypass).

Also fixed while re-verifying: my two uploads-router tests were writing into the real global uploads directory — now isolated to tmp_path via patched get_uploads_dir/ensure_uploads_dir (matching the existing isolation pattern in test_uploads_router.py). 18 sandbox-authz tests + 344 across the affected suites pass; the one intermittent file-replacement flake I chased reproduces identically on upstream/main (Windows file-lock timing, pre-existing — not introduced here).

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

Re-reviewed at head c6a17da (previous review was at 069aeee). All three round-1 findings are resolved and each carries a regression test: the eager-path deny now skips acquisition instead of raising a graph-level error, provider-resolution failures follow fail_closed/fail_open inside authorize_sandbox_execution, and the user is None branch is documented in both routers. The delta looks correct - I verified the uploads/artifacts deny paths leave sandbox = None so the sync loop and the release/rollback branches are safely skipped, and the middleware return None is equivalent to the base-class no-op. Two small nits below: a stale (now unreachable) fallback layer in authorize_sandbox_for_request, and a missing deny-path regression test for the artifacts router's copy of the gate.

)
except SandboxAuthorizationError:
raise
except Exception:

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.

Nit: after the fix in authorize_sandbox_execution (this push moved provider resolution inside its try), this except Exception fallback is unreachable in practice - resolution and authorize() errors are both converted to SandboxAuthorizationError (or swallowed under fail-open) one layer down, and the context built by _route_authz_context has no authz_attributes, so build_principal_from_context can't raise here either. The comment ("Provider resolution failures ... must not 500 the route") is now stale since those never reach this layer. If you keep it as defense-in-depth, worth rewording the comment; also note this fallback consults config.fail_closed from _get_route_authorization_config() while the helper uses the route-injected app_config's authorization config - two config sources in one gate, which would diverge if the injected config ever differs from the global one.

# above (401), and auth-disabled mode stamps a user via
# AuthMiddleware. Same reasoning as the uploads router.
if user is not None:
authorize_sandbox_for_request(user, is_internal=_is_internal_caller(request, user), app_config=get_config())

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.

Nit: this copy of the deny-skips-sync gate has no regression test - test_sandbox_authorization.py covers the uploads router's deny/allow paths (test_upload_sandbox_sync_skipped_when_denied / ..._proceeds_when_allowed) but nothing asserts the artifacts route skips acquire_async (and still completes the host-side update) for a denied role. Since the PR's mutation-testing claim covers only the tools.py gate, a reverted gate here would pass the suite unnoticed. The _make_upload_app harness could be reused with the artifacts router for a two-line deny test.

@WillemJiang

Copy link
Copy Markdown
Collaborator

@hata33 Please fix the unit test and backend IO test errors.

@hata33
hata33 force-pushed the feat/authz-phase3-sandbox branch from c6a17da to c17f85d Compare August 21, 2026 01:35
@github-actions github-actions Bot added the area:agents Agents, subagents, graph wiring, prompts, langgraph.json label Aug 21, 2026
@hata33

hata33 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

@WillemJiang CI failures fixed in c17f85d8; @willem-bd both nits addressed in the same push.

CI backend-unit-tests failures — root cause: the gate called get_app_config(), which raises FileNotFoundError in config-less environments (CI has no config.yaml; my local checkout had one, which is why it passed locally and failed on CI). Fixed with safe_app_config() in sandbox_authz.py: it loads the global config and returns None when unavailable — and since authorize_sandbox_execution treats a None app_config exactly like authorization.enabled: false (the existing mock-safe getattr guard), no config ⇒ gate is a no-op, no new failure mode. The four gate call sites (ensure_sandbox_initialized ×2, middleware ×2) now use it. Regression: test_authorize_sandbox_no_config_file_is_noop patches get_app_config to raise and asserts the gate stays a no-op.

CI backend-blocking-io failure — root cause: direct-call tests invoke upload_files(request=None), and get_optional_user_from_request(None) dereferences request.cookies on the no-state-user path. Fixed with an explicit request is None guard before the user lookup in both routers (gate skipped — same fail-open semantics as the anonymous bypass). Regression: test_upload_gate_tolerates_request_none.

Nit 1 (stale wrapper fallback): the except Exception in authorize_sandbox_for_request is kept as defense-in-depth — it is still reachable for config-read failures (get_config() raising in a config-less environment), which the lower layer never sees. Comment rewritten to say exactly that instead of the stale "provider resolution" rationale.

Nit 2 (artifacts deny coverage): added test_artifact_sandbox_sync_skipped_when_denied — a denied role's artifact update completes host-side (note.txt == "after"), acquire_async (mocked to raise) is never called. The uploads/artifacts deny paths now have symmetric coverage.

Verified locally: 21 sandbox-authz tests, the full affected set including tests/blocking_io/test_uploads_router.py (the exact CI failure) — all pass. The 4 remaining local blocking_io failures (channel_runtime_config_store chmod semantics, integrations_router lark) reproduce identically on upstream/main — Windows-environment-only, unrelated to this PR.

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

Round 3 at c17f85d — all five prior findings are verified fixed at this head (resolution moved inside the try, eager-path deny now skips acquisition instead of raising, artifacts deny regression test added, request=None guard, defense-in-depth comment reworded). The gate placement, reuse-path skip, uploads/artifacts deny-skips-sync behavior, and fail-closed/open handling all check out against the head files. Two new suggestion-level findings, both documentation-adjacent: (1) the guest example role allows read_file while denying sandbox:execute, but read_file is itself a sandbox-dependent tool (it unconditionally calls ensure_sandbox_initialized), so the advertised "read-only role" permits a tool that can only ever return the deny error; (2) the AGENTS.md addition documents the previous phase's model authorization while this PR's own sandbox gate gets no module-guide coverage. Code itself looks good from my side.

Comment thread config.example.yaml Outdated
# tools: {allow: ["web_search", "read_file"]}
# routes: {allow: ["threads:read", "runs:read"]}
# models: {allow: ["gpt-4o-mini"]}
# sandbox: {allow: false} # deny sandbox execution (read-only role)

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.

This example role combination is self-contradicting: guest allows read_file at the tool level while sandbox: {allow: false} denies the sandbox — but read_file is itself a sandbox-dependent tool (read_file_tool_read_file_from_sandboxensure_sandbox_initialized, unconditionally, verified at this head). With this policy, every guest read_file call hits the new gate and returns "Error: Sandbox execution is not permitted for your role". So the "read-only role" comment doesn't hold: the one read tool it allows can never succeed, and the role is effectively web_search-only. Suggest either dropping read_file from the guest tools allow list, or rewording the comment to make explicit that read_file/glob/grep all require sandbox:execute and will error for this role (operators copying this example otherwise ship a tool allow-list whose entries always fail).


Gateway route authorization uses `authz.py::resolve_route_permissions()` as the single provider integration point for both `AuthMiddleware` and decorator-only authentication. When enabled, it evaluates the six registered `threads:*` / `runs:*` permissions as `resource="route"` requests whose targets are the full `resource:action` strings. Decisions use the async provider API and are cached for the request in `AuthContext`; decorators do not call the provider again. Provider resolution or decision errors follow `authorization.fail_closed`, scoped per permission for decision errors. When authorization is disabled, the legacy complete permission set is returned without resolving a provider. Existing `owner_check` enforcement and `require_admin_user()` management gates remain independent and unchanged. Tests: `tests/test_authorization_route_permissions.py`, `tests/test_auth.py`, and `tests/test_auth_middleware.py`.

Model authorization uses `authz.py::resolve_model_authorization()` (same cached-provider, internal-role, and principal-building path as route authorization) as the Gateway integration point for the `models` router: `list_models` filters names through `filter_resources(principal, "model", ...)`, and `get_model` enforces `authorize(resource="model", action="use")` with a deny surfacing as 403; provider errors follow `authorization.fail_closed` (fail-open returns the unfiltered list / proceeds). At runtime, `lead_agent/agent.py::_authorize_model_name` — called from `_make_lead_agent` and from `DeerFlowClient._ensure_agent` — applies the same `model:use` check to the resolved model name. On deny it scans the `filter_resources`-visible names (excluding the denied model), re-verifying each candidate with `authorize("model", "use")` before falling back, because a custom provider may allow `list` while denying `use`; no usable fallback raises under `fail_closed` and keeps the original model under fail-open. The built-in RBAC provider maps this to the per-role `models` policy key. Tests: `tests/test_models_authorization.py`.

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.

Doc-sync gap: this added paragraph backfills the models phase (#4540) documentation, but this PR's own change — the sandbox:execute gate — is documented in neither module guide. sandbox/AGENTS.md (which owns acquisition lifecycle, fail-closed semantics, and tools.py behavior in detail) is untouched by this PR, and no sandbox paragraph lands here next to the models one, even though SandboxMiddleware.before_agent/abefore_agent are modified in this diff. Per the repo's documentation-update policy ("update the relevant AGENTS.md for development/architecture changes in the same change set"), consider adding a short paragraph covering: gate at ensure_sandbox_initialized + the eager-path skip-defer-to-lazy-gate decision, SandboxAuthorizationError surfacing as a ToolMessage via the existing except SandboxError handling in the tools, the uploads/artifacts deny-skips-sync behavior, and the deferred feishu/dingtalk paths — otherwise the next agent touching the sandbox module has to rediscover the gate from the implementation notes.

@hata33
hata33 force-pushed the feat/authz-phase3-sandbox branch from c17f85d to 5ca1b2e Compare August 21, 2026 02:36
@hata33

hata33 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Both documentation suggestions addressed in 5ca1b2e0 — and thank you for catching the self-contradicting example.

Guest role example — fixed. read_file dropped from the guest tools allowlist (it unconditionally goes through ensure_sandbox_initialized, so under sandbox: {allow: false} it could only ever return the deny error). The guest example is now a clean web-only role — tools: {allow: ["web_search"]} + sandbox: {allow: false} — with a comment explaining that sandbox-dependent tools (read_file, bash, glob, grep, write_file, ...) are omitted precisely because they'd be dead under a sandbox deny.

Module-guide coverage — added. The sandbox:execute gate now has a full paragraph in sandbox/AGENTS.md (the module guide that owns the acquisition lifecycle): gate placement at the single acquisition entry point, reuse-path skip, deny semantics on both paths (ToolMessage on the lazy path, skip-and-defer on the eager path), fail-closed/open including provider-resolution errors, safe_app_config no-config behavior, the Gateway auxiliary sync paths, and the test file. agents/middlewares/AGENTS.md (whose SandboxMiddleware this PR modifies) gained a short cross-reference paragraph next to the models one. The models paragraph itself is an intentional backfill of #4540's guide coverage that rode along in this branch.

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

Round 4 at 5ca1b2e (squashed head; re-verified the full diff plus surrounding code at this commit). All seven findings from rounds 1-3 are confirmed fixed here: the eager-path deny now skips acquisition with a regression test, provider resolution moved inside the try with fail-closed/fail-open tests, the request=None/user=None guards and the defense-in-depth comment are in place, the artifacts deny path has coverage, the guest example no longer allows sandbox-dependent tools alongside sandbox: {allow: false}, and both module guides document the gate. I also re-verified the completeness claim at this head: every provider.acquire/acquire_async call site outside tests is either gated (tools.py, middleware eager path, uploads, artifacts) or is the documented feishu/dingtalk deferral; the RBAC provider maps the sandbox key and handles "*"/bool/list allow forms as described; and the friendly-ToolMessage claim holds because the tool bodies and _run_sync_tool_after_async_sandbox_init convert SandboxError to an error string. Three remaining items, all suggestion/nit level: deny-path regression coverage for the async gate copies, the duplicated router gate blocks, and the app_config type annotation.


# Phase 3: enforce sandbox:execute authorization before acquiring (async
# counterpart of the sync gate in ``ensure_sandbox_initialized``).
authorize_sandbox_execution(

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.

Suggestion: the async copies of the gate have no deny-path regression coverage. test_ensure_sandbox_initialized_denies_on_authz_reject and test_eager_before_agent_deny_skips_acquisition cover only the sync ensure_sandbox_initialized / before_agent, while the async runtime paths the agent actually executes go through ensure_sandbox_initialized_async (here) and abefore_agent (middleware.py). These are verbatim copies of the gated sync code, so reverting or deleting either async gate would leave the whole suite green — which undercuts the PR's mutation-testing claim. One deny test each (mirroring the sync ones, asserting acquire_async is never called) would close that gap.

Comment thread backend/app/gateway/routers/uploads.py Outdated
# semantics as the models routes' anonymous bypass.
user = await get_optional_user_from_request(request) if request is not None else None
if user is not None:
authorize_sandbox_for_request(user, is_internal=_is_internal_caller(request, user), app_config=config)

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.

Suggestion: uploads.py and artifacts.py now carry near-identical ~15-line gate blocks (optional-user lookup → authorize_sandbox_for_request → except-log → else acquire) plus duplicated rationale comments. The PR's own design principle for the harness side was to gate at a single acquisition entry point precisely so N copies can't drift; the same applies here. Consider hoisting a shared helper next to authorize_sandbox_for_request — e.g. an async try_acquire_sandbox_for_request(request, provider, thread_id, user_id, *, app_config) -> Sandbox | None that returns None on deny — so both routers call one function and the deny/skip semantics (including the log line) live in one place.

_SANDBOX_TARGET = "*"


def authorize_sandbox_execution(*, context: Mapping[str, Any], app_config: AppConfig) -> 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.

Nit: the annotation says app_config: AppConfig, but every harness caller passes safe_app_config(), which returns None whenever the config is unreadable — and test_authorize_sandbox_no_config_file_is_noop passes app_config=None explicitly. safe_app_config itself has no return annotation either. Typing these as AppConfig | None would document the "None is treated as disabled" contract at the signature instead of only in the docstring.

@hata33
hata33 force-pushed the feat/authz-phase3-sandbox branch from 5ca1b2e to e426dd1 Compare August 21, 2026 15:11
@hata33

hata33 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

All three round-4 suggestions addressed in e426dd11.

Async gate coverage — added. test_ensure_sandbox_initialized_async_denies_on_authz_reject (asserts acquire_async is never called on deny) and test_abefore_agent_deny_skips_acquisition (async counterpart of the eager-path test: returns None, _acquire_sandbox_async untouched). Mutation-tested: deleting either async gate copy now makes the suite red.

Duplicated router gate blocks — hoisted. New shared try_acquire_sandbox_for_request(request, sandbox_provider, thread_id, *, user_id, app_config) in gateway/authz.py, sitting next to authorize_sandbox_for_request. It returns (sandbox, sandbox_id, denied) so the two callers keep their distinct infrastructure-error surfacing (uploads: 500, artifacts: RuntimeError when the provider loses a just-acquired sandbox) while the deny/skip semantics and the log line live in exactly one place. Both routers are now a single call plus their error branch. Two things the re-review caught before push, worth noting since they were introduced and fixed within this round: (1) an initial single-None return conflated deny with acquire-failure (would have 500'd denied uploads) — resolved by the denied flag; (2) the hoist dropped the sandbox_id assignment the artifacts release path depends on (acquired sandboxes would never be released) — caught by the existing release tests, fixed by returning sandbox_id and re-verified.

Type annotations — corrected. authorize_sandbox_execution and authorize_sandbox_for_request now take app_config: AppConfig | None, safe_app_config is annotated -> AppConfig | None, and the "None is treated as disabled" contract is stated in the signature docstring.

Also synced during re-review: the sandbox AGENTS.md guide and implementation notes now name the shared helper, and the README's RBAC policy list includes skills/sandbox. 353 tests across the affected suites pass; lint/format clean repo-wide.

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

Round 5 at e426dd1 (squashed head; re-verified the full diff plus the surrounding code at this commit). All findings from rounds 1-4 are confirmed fixed here: the duplicated uploads/artifacts gate blocks are replaced by the shared try_acquire_sandbox_for_request (artifacts still passes sandbox_id through to the finally release, uploads guards the sync loop on sandbox is not None), the async deny paths now have regressions (test_ensure_sandbox_initialized_async_denies_on_authz_reject, test_abefore_agent_deny_skips_acquisition), AppConfig | None annotations are in place at both the gate and safe_app_config, the guest example allows only web_search alongside sandbox: {allow: false}, and both the sandbox and middlewares module guides document the gate. I also re-checked the new helper's edge cases at this head: _is_internal_caller exists and is getattr-guarded, the middleware gate sits after the lazy_init skip and inside the no-existing-sandbox branch, and the sync authorize() call from the async routes matches the established models-route pattern (the provider Protocol requires both sync and async methods). One new nit below — the implementation notes' evidence section under-counts the test file.

(直接返回)。RBAC provider 的 `_RESOURCE_POLICY_KEYS` 已包含 `"sandbox": "sandbox"`,
`provider.py` 已声明 `"sandbox"` 为有效 resource,无需 schema 变更。对 test mock
(SimpleNamespace app_config)安全:使用 `getattr` + `is not True` 防御。
- **证据:** `tests/test_sandbox_authorization.py`(14 tests)覆盖 disabled/RBAC allow/deny/

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.

Nit: this evidence section says tests/test_sandbox_authorization.py(14 tests), but at this head the file contains 23 tests — the enumeration predates the round 1-4 regressions and now under-reports the coverage: eager-path deny-skip for both before_agent and abefore_agent, provider-resolution error under fail-open/fail-closed, ensure_sandbox_initialized_async deny, the artifacts-router deny-skips-sync path, request=None tolerance, the no-config no-op, and the mock-app_config guard are all tested but unlisted. Since the notes are the cumulative handoff record for reviewer feedback and required regression coverage, updating the count and coverage list here keeps the Phase 3 → Phase 4 handoff accurate.

@hata33
hata33 force-pushed the feat/authz-phase3-sandbox branch from e426dd1 to 3aa6fb7 Compare August 22, 2026 01:42
@hata33

hata33 commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 3aa6fb74 — the evidence section now reads 23 tests and enumerates the full coverage added across the review rounds: the sync+async ensure_sandbox_initialized deny/allow integrations, both eager-path deny-skip regressions (before_agent / abefore_agent), provider-resolution fail-closed/fail-open (including the inverted-fail-open regression), the uploads/artifacts router deny-skips-sync paths, request=None tolerance, the no-config no-op, and the mock-app_config guard. Verified the count against a fresh run (23 passed).

@WillemJiang WillemJiang added this to the 2.1.0 milestone Aug 23, 2026
@WillemJiang

Copy link
Copy Markdown
Collaborator

@hata33 please fix the unit test errors.

…tion (bytedance#4063 Phase 3)

Sandbox is an execution environment, not a named resource: multiple tools
(bash, read_file, write_file, glob, grep, ...) depend on it, all funneled
through ensure_sandbox_initialized / ensure_sandbox_initialized_async. Gate
the single acquisition entry point (single source of truth) instead of
maintaining a sandbox-tool-name set in middleware:

- authorize_sandbox_execution helper (authz/sandbox_authz.py) checks
  authorize("sandbox", "execute", target="*") — a binary judgment
  (can this role use the sandbox at all); RBAC allow:"*"/true permits,
  allow:[]/false denies.
- lazy path: ensure_sandbox_initialized (+ async) calls the gate before
  provider.acquire.
- eager path: SandboxMiddleware.before_agent / abefore_agent call the gate
  before _acquire_sandbox.
- deny raises SandboxAuthorizationError (SandboxError subclass) which
  propagates through tool execution as a friendly ToolMessage (RFC §9:
  'not a crash').
- authorization.enabled: false is a no-op everywhere; provider errors
  follow fail_closed (deny) / fail_open (allow).

12 tests in tests/test_sandbox_authorization.py cover disabled/allow/deny/
deny-via-bool/no-policy-unrestricted/provider-error-fail-closed/open/
internal-caller + ensure_sandbox_initialized deny (never acquires) and
allow (acquires) integration paths.
@hata33
hata33 force-pushed the feat/authz-phase3-sandbox branch from 3aa6fb7 to 8841ff6 Compare August 23, 2026 07:59
@hata33

hata33 commented Aug 23, 2026

Copy link
Copy Markdown
Contributor Author

@WillemJiang fixed in 8841ff69 — root cause reproduced and verified locally this time before pushing.

What failed: the three test_update_artifact_syncs/releases/rolls_back tests on CI (passed locally). Root cause: the artifacts router passed app_config=get_config() as an eagerly-evaluated argument to try_acquire_sandbox_for_request — so get_config() ran before any gate logic, and on CI (no config.yaml in the checkout) it raised FileNotFoundError. Local runs had a config.yaml, masking it.

Fix: the artifacts router now passes safe_app_config() (the same config-tolerant loader the harness gates use) — unreadable config ⇒ None ⇒ the gate treats it as authorization.enabled: false, no new failure mode. Verified in both environments: with a local config.yaml (145 passed) and with it removed to mirror CI (171 passed, including the three failing tests). Also re-ran the mutation check after the change: removing the gate still fails both router deny regressions.

@WillemJiang
WillemJiang merged commit cc6a265 into bytedance:main Aug 24, 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:backend Gateway / runtime / core backend under backend/ 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.

3 participants