Repository navigation
feat(guardrails): expose authenticated runtime context in GuardrailRequest - #3665
Conversation
…GuardrailRequest Extend GuardrailRequest with optional runtime attribution fields so that pluggable GuardrailProviders can access authenticated user context and tool-call-level attribution: - Gateway injects user_role, oauth_provider, oauth_id into runtime context alongside the existing user_id (server-authenticated only, client spoofing prevented) - GuardrailRequest gains: user_id, user_role, oauth_provider, oauth_id, run_id, tool_call_id (all optional, backward compatible) - GuardrailMiddleware reads these from ToolCallRequest.runtime.context - thread_id now actually populated from context (was always None before) - Tests: 15 new/expanded tests covering Gateway injection, runtime context reading, partial/missing fields, and client spoofing prevention - Docs: new Runtime Attribution section in GUARDRAILS.md with provider example and YAML policy illustration
fancyboi999
left a comment
There was a problem hiding this comment.
Traced the full identity path and the security property holds — the new authenticated fields are genuinely server-only and can't be spoofed. Verified on head 92c2486:
- Not client-spoofable.
_CONTEXT_CONFIGURABLE_KEYS(app/gateway/services.py:127-139) doesn't includeuser_role/oauth_provider/oauth_id, somerge_run_context_overridesnever copies them out of clientbody.context. The only writer isinject_authenticated_user_context(services.py:186-189), which sources them fromrequest.state.user(server auth state). And the call order (services.py:407-408) is merge-then-inject, so foruser_idthe server value overwrites any client-supplied one. The dedicated spoof test (test_setup_agent_e2e_user_isolation.py— "Spoofed client identity fields must not override the authenticated user", assertsuser_role == "user"andoauth_* is Nonewhen the client sendsadmin/spoofed) pins exactly this. I ran both guardrail test files: 44 passed. - Backward-compatible. The six new
GuardrailRequestfields (guardrails/provider.py:19-24) all default toNone/False, so existing providers are unaffected, andmiddleware._build_requestreadsruntime.contextbehind anisinstance(..., dict)guard, so a missing/empty context just yieldsNones (covered bytest_no_attribution_fields_are_none). - Sourcing is real.
oauth_provider/oauth_idare actualUsercolumns (persistence/user/model.py:44-45) andsystem_roleexists, so thegetattrlookups resolve rather than silently returningNone.
One neutral note for readers: user_role is mapped from user.system_role (services.py:187) — the rename is fine (a guardrail-facing name is arguably clearer than system_role), just flagging the provenance so it's not mistaken for a separate field.
Looks correct, secure, and mergeable — nice that the spoof-resistance is encoded as a test rather than left implicit.
willem-bd
left a comment
There was a problem hiding this comment.
Review: guardrail runtime attribution
Overall this is a clean, backward-compatible addition: GuardrailRequest gets optional fields, _build_request reads them defensively from ToolRuntime.context (verified populated through the config["context"] -> Runtime.context -> ToolCallRequest.runtime.context chain), and the server-authenticated identity injected in inject_authenticated_user_context correctly overwrites any client-supplied values for web users. The new tests cover the lead-agent web path well.
The main concern is coverage, not correctness of the lines changed -- the newly-exposed user_id/user_role/run_id silently arrive as None on two other tool-call paths where GuardrailMiddleware also runs:
- Subagent tool calls (highest priority): the subagent executor builds its runtime context without propagating
user_id/user_role/run_id, so delegated tool calls are evaluated withuser_role=None. Since the docs actively encourage role-based policy, this silently breaks it for any delegated work. - IM/internal-auth runs:
injectearly-returns forINTERNAL_SYSTEM_ROLE, so channel-originated runs also getuser_role=None.
Two smaller notes inline: the spoofing test passes for the wrong reason (it doesn't exercise inject's overwrite), and the doc example does blocking file I/O on the async path (would trip the repo's blocking-IO gate if adopted).
No correctness bug in the diff itself -- flagging these so the feature delivers the attribution it advertises across all run paths. Happy to help wire the subagent context if that's in scope here.
| agent_id=self.passport, | ||
| thread_id=context.get("thread_id"), | ||
| timestamp=datetime.now(UTC).isoformat(), | ||
| user_id=context.get("user_id"), |
There was a problem hiding this comment.
Coverage gap -- subagent tool calls get user_id/user_role/run_id = None here.
GuardrailMiddleware is attached to subagents too (build_subagent_runtime_middlewares -> _build_runtime_middlewares, tool_error_handling_middleware.py:161-181 appends it whenever guardrails.enabled). But the subagent's ToolRuntime.context is built fresh in subagents/executor.py:561-566 with only thread_id + app_config -- it never copies in user_id/user_role/run_id, even though the executor already holds self.user_id (executor.py:324).
Impact: if a deployment uses the role-based guardrail policy this PR's docs promote (e.g. "allow bash only for admin"), any tool call delegated to a subagent is evaluated with user_role=None -> role_tool_key=":bash" never matches "admin:bash", so subagent bash is either wrongly denied or falls to default-allow -- contradicting the policy that governs lead-agent calls. Lead-agent calls work; only delegated calls are silently mis-attributed.
Suggested fix: in SubagentExecutor._aexecute, also set context["user_id"] = self.user_id (and thread the parent run_id/user_role into the subagent's initial state) before agent.astream(..., context=context).
There was a problem hiding this comment.
Addressed in the follow-up commits. I also added a PR-level summary with local verification screenshots.
| runtime_context = config.setdefault("context", {}) | ||
| if isinstance(runtime_context, dict): | ||
| runtime_context["user_id"] = str(user_id) | ||
| runtime_context["user_role"] = getattr(user, "system_role", None) |
There was a problem hiding this comment.
user_role/oauth_provider/oauth_id stay None for IM/internal-auth runs.
inject_authenticated_user_context early-returns at services.py:181-182 for INTERNAL_SYSTEM_ROLE, which is exactly what internal_auth.py:43 stamps on every IM/internal request (SimpleNamespace(id=DEFAULT_USER_ID, system_role="internal")). And these three keys aren't in _CONTEXT_CONFIGURABLE_KEYS (services.py:124-139), so merge_run_context_overrides never carries them from body.context either.
So an IM-originated run (Slack/Discord/Telegram) where the bound owner is an admin still evaluates its tool calls with user_role=None. user_id survives (via the special body.context user_id setdefault branch), but role-based guardrail policy can't be applied to channel-delivered work. Worth at least documenting as a known limitation, or threading the owner's system_role alongside the owner user_id.
| ) | ||
| runtime_ctx = _build_runtime_context("thread-e2e", "run-1", config.get("context"), None) | ||
| assert runtime_ctx["user_id"] == "11111111-2222-3333-4444-555555555555" | ||
| assert runtime_ctx["user_role"] == "user" |
There was a problem hiding this comment.
This assertion (and the oauth_* is None ones below) passes for the wrong reason -- it can't detect a regression in inject's overwrite defense.
user_role/oauth_provider/oauth_id are not in _CONTEXT_CONFIGURABLE_KEYS (services.py:124-139), so merge_run_context_overrides never copies them from body_context in the first place -- they're None in the output not because inject overrode them, but because they were never accepted. The test would still pass if inject were removed for those fields.
The actual spoofing vector is body.config.context, which build_run_config copies wholesale (services.py:248-253) and which only inject's unconditional = assignment (services.py:186-189) defeats. Consider asserting against a body.config={"context": {...}} input to actually exercise inject's overwrite.
| return decision | ||
|
|
||
| async def aevaluate(self, request): | ||
| return self.evaluate(request) |
There was a problem hiding this comment.
This skeleton does blocking file I/O on the async guardrail path.
aevaluate calls self.evaluate, which performs synchronous self.audit_path.open("a") + f.write(...) (GUARDRAILS.md:358). awrap_tool_call runs on the event loop, so a provider copied from this example blocks the loop on every audit write. The repo enforces a blocking-IO gate in CI (backend/tests/blocking_io, .github/workflows/backend-blocking-io-tests.yml hard-fails), so this pattern would also trip the gate if adopted into real code.
The comment added above MyGuardrailProvider.aevaluate (lines 233-235) flags async policy I/O but not this audit write. Consider making _write_audit async (e.g. aiofiles/asyncio.to_thread) and awaiting it from aevaluate.
5a699e2 executor GuardrailRequest 加 user_id/user_role/run_id/oauth 字段 task_tool 传递 parent runtime context 到子代理, GuardrailMiddleware 可做 role-aware 鉴权 3处冲突均HEAD空+上游新增(纯增量), sed取上游
…edance#3665) 09988ca bytedance#3861 skills request-scoped secrets(secret_context 注入沙箱环境变量) 5a699e2 bytedance#3665 guardrail GuardrailRequest 暴露 user_id/role/run_id 上下文 e2b_sandbox DU 取 eai 删; AGENTS.md 取 eai 版; sandbox/services 冲突 HEAD 空+上游新增
…quest (bytedance#3665) * docs: guardrail runtime attribution spec * docs: guardrail request attribution implementation plan * feat(guardrails): add runtime user context and attribution fields to GuardrailRequest Extend GuardrailRequest with optional runtime attribution fields so that pluggable GuardrailProviders can access authenticated user context and tool-call-level attribution: - Gateway injects user_role, oauth_provider, oauth_id into runtime context alongside the existing user_id (server-authenticated only, client spoofing prevented) - GuardrailRequest gains: user_id, user_role, oauth_provider, oauth_id, run_id, tool_call_id (all optional, backward compatible) - GuardrailMiddleware reads these from ToolCallRequest.runtime.context - thread_id now actually populated from context (was always None before) - Tests: 15 new/expanded tests covering Gateway injection, runtime context reading, partial/missing fields, and client spoofing prevention - Docs: new Runtime Attribution section in GUARDRAILS.md with provider example and YAML policy illustration * fix(guardrails): propagate attribution to subagents * fix(guardrails): complete subagent attribution propagation --------- Co-authored-by: Miracle778 <miracle778@no-reply.com>
…quest (bytedance#3665) * docs: guardrail runtime attribution spec * docs: guardrail request attribution implementation plan * feat(guardrails): add runtime user context and attribution fields to GuardrailRequest Extend GuardrailRequest with optional runtime attribution fields so that pluggable GuardrailProviders can access authenticated user context and tool-call-level attribution: - Gateway injects user_role, oauth_provider, oauth_id into runtime context alongside the existing user_id (server-authenticated only, client spoofing prevented) - GuardrailRequest gains: user_id, user_role, oauth_provider, oauth_id, run_id, tool_call_id (all optional, backward compatible) - GuardrailMiddleware reads these from ToolCallRequest.runtime.context - thread_id now actually populated from context (was always None before) - Tests: 15 new/expanded tests covering Gateway injection, runtime context reading, partial/missing fields, and client spoofing prevention - Docs: new Runtime Attribution section in GUARDRAILS.md with provider example and YAML policy illustration * fix(guardrails): propagate attribution to subagents * fix(guardrails): complete subagent attribution propagation --------- Co-authored-by: Miracle778 <miracle778@no-reply.com>



Closes #3664
Why
当前
GuardrailProvider已经可以返回结构化的allow/deny决策,包括reasons、policy_id和metadata。但传给 provider 的GuardrailRequest缺少足够的已认证运行时上下文。现在 provider 主要能看到 tool name 和 tool input,但无法稳定知道:
thread_id、run_id或tool_call_id。这会让多用户部署、role-aware tool policy、provider 侧审计日志等 custom provider 场景变得更难实现。
这个 PR 保持 Guardrail 的职责边界不变:不新增 policy engine、RBAC 系统、governance 子系统,也不改变默认行为。它只把 DeerFlow 已经掌握的 authenticated runtime context 传入
GuardrailRequest,让 custom provider 在需要时可以消费这些上下文。What changed
GuardrailRequest新增以下 optional runtime context 字段:user_iduser_roleoauth_provideroauth_idrun_idtool_call_idthread_id现在会从ToolCallRequest.runtime.context填充;该字段之前已经存在,但GuardrailMiddleware没有实际填充。request.state.user注入 authenticated user context:user_iduser_roleoauth_provideroauth_idGuardrailMiddleware从ToolCallRequest.runtime.context读取新增字段,并从request.tool_call读取 tool call id。backend/docs/GUARDRAILS.md新增 Runtime Attribution 文档,并展示 custom provider 如何把这些字段归一化成 provider-defined policy context。docs/superpowers中的 design spec 和 implementation plan 也同步更新,记录这次字段来源、兼容性和测试覆盖。Surface area
frontend/backend/applanggraph.json, or prompt changedocker/or sandboxed executionskills/backend/pyproject.tomlorfrontend/package.json(say what it buys us)说明:
GuardrailRequest字段都是 optional,现有 provider 不读取这些字段时行为不变。Screenshots / Recording
没有前端 UI 改动。
我也用一个 custom
GuardrailProvider验证了新增字段可以支持 role-aware policy。provider 会派生一个 provider-defined key:本地示例 policy 行为:
本地验证结果:
bash-> 命中deny-user-bash,拒绝执行bash-> 命中allow-admin-bash,允许执行GuardrailRequest:普通用户记录为user_role: "user",派生出role_tool_key: "user:bash"并命中policy_id: "deny-user-bash";admin 用户记录为user_role: "admin",派生出role_tool_key: "admin:bash"并命中policy_id: "allow-admin-bash"。Validation
cd backend PYTHONPATH=. uv run pytest \ tests/test_guardrail_middleware.py::TestGuardrailRequestAttribution \ tests/test_setup_agent_e2e_user_isolation.py::TestConfigAssembly -v结果:
AI assistance
Tool(s) used: Codex
How you used it: Codex 帮助阅读现有 Guardrails 实现、整理 issue / PR 文案、更新文档和测试,并基于本地 DeerFlow 源码 review 这次改动。最终 scope 收敛为:只把 authenticated runtime context 传入
GuardrailRequest,不引入新的 policy engine 或 governance 子系统。