Skip to content

feat(guardrails): expose authenticated runtime context in GuardrailRequest - #3665

Merged
WillemJiang merged 5 commits into
bytedance:mainfrom
Miracle778:guardrail-runtime-context
Jun 21, 2026
Merged

WillemJiang merged 5 commits into
bytedance:mainfrom
Miracle778:guardrail-runtime-context

Conversation

@Miracle778

@Miracle778 Miracle778 commented Jun 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes #3664

Why

当前 GuardrailProvider 已经可以返回结构化的 allow/deny 决策,包括 reasons、policy_id 和 metadata。但传给 provider 的 GuardrailRequest 缺少足够的已认证运行时上下文。

现在 provider 主要能看到 tool name 和 tool input,但无法稳定知道:

  • 是哪个已认证 DeerFlow 用户触发了这次工具调用;
  • 这个用户在 DeerFlow 内部是什么角色;
  • 如果未来接入 OAuth / SSO,这个用户对应哪个外部 identity provider / subject;
  • 这次决策属于哪个 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_id
    • user_role
    • oauth_provider
    • oauth_id
    • run_id
    • tool_call_id
  • thread_id 现在会从 ToolCallRequest.runtime.context 填充;该字段之前已经存在,但 GuardrailMiddleware 没有实际填充。
  • Gateway 从 request.state.user 注入 authenticated user context:
    • user_id
    • user_role
    • oauth_provider
    • oauth_id
  • client-supplied context 不能伪造或覆盖服务端认证态用户上下文。
  • GuardrailMiddleware 从 ToolCallRequest.runtime.context 读取新增字段,并从 request.tool_call 读取 tool call id。
  • backend/docs/GUARDRAILS.md 新增 Runtime Attribution 文档,并展示 custom provider 如何把这些字段归一化成 provider-defined policy context。
  • 测试覆盖 Gateway 注入、防伪造覆盖、middleware request 构造、缺失 context 的行为,以及现有 provider 的兼容性。
  • docs/superpowers 中的 design spec 和 implementation plan 也同步更新,记录这次字段来源、兼容性和测试覆盖。

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 (say what it buys us)
  • Default behavior change — changes existing behavior without the user opting in (default model, default setting, data shape)
  • Docs / tests / CI only — no runtime behavior change

说明:

  • 没有新增外部依赖。
  • 本 PR 修改了 Gateway runtime config assembly,但没有改变任何公开 endpoint、SSE event 或 request/response shape。
  • 所有新增 GuardrailRequest 字段都是 optional,现有 provider 不读取这些字段时行为不变。
  • 默认 allow/deny 行为不变。

Screenshots / Recording

没有前端 UI 改动。

我也用一个 custom GuardrailProvider 验证了新增字段可以支持 role-aware policy。provider 会派生一个 provider-defined key:

role_tool_key = f"{request.user_role or ''}:{request.tool_name}"

本地示例 policy 行为:

rules:
  - name: allow-admin-bash
    condition:
      field: role_tool_key
      operator: eq
      value: admin:bash
    action: allow

  - name: deny-user-bash
    condition:
      field: role_tool_key
      operator: eq
      value: user:bash
    action: deny

本地验证结果:

  • 普通用户 + bash -> 命中 deny-user-bash,拒绝执行
img
  • admin 用户 + bash -> 命中 allow-admin-bash,允许执行
img_1
  • Audit 输出进一步确认 DeerFlow 认证后的角色已经传入 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"。
img_2

Validation

cd backend
PYTHONPATH=. uv run pytest \
  tests/test_guardrail_middleware.py::TestGuardrailRequestAttribution \
  tests/test_setup_agent_e2e_user_isolation.py::TestConfigAssembly -v

结果:

15 passed

AI assistance

Tool(s) used: Codex

How you used it: Codex 帮助阅读现有 Guardrails 实现、整理 issue / PR 文案、更新文档和测试,并基于本地 DeerFlow 源码 review 这次改动。最终 scope 收敛为:只把 authenticated runtime context 传入 GuardrailRequest,不引入新的 policy engine 或 governance 子系统。

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

…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
@CLAassistant

CLAassistant commented Jun 20, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions Bot added area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only 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 Jun 20, 2026

@fancyboi999 fancyboi999 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 include user_role/oauth_provider/oauth_id, so merge_run_context_overrides never copies them out of client body.context. The only writer is inject_authenticated_user_context (services.py:186-189), which sources them from request.state.user (server auth state). And the call order (services.py:407-408) is merge-then-inject, so for user_id the 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", asserts user_role == "user" and oauth_* is None when the client sends admin/spoofed) pins exactly this. I ran both guardrail test files: 44 passed.
  • Backward-compatible. The six new GuardrailRequest fields (guardrails/provider.py:19-24) all default to None/False, so existing providers are unaffected, and middleware._build_request reads runtime.context behind an isinstance(..., dict) guard, so a missing/empty context just yields Nones (covered by test_no_attribution_fields_are_none).
  • Sourcing is real. oauth_provider/oauth_id are actual User columns (persistence/user/model.py:44-45) and system_role exists, so the getattr lookups resolve rather than silently returning None.

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.

@WillemJiang WillemJiang added this to the 2.1.0 milestone Jun 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.

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:

  1. 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 with user_role=None. Since the docs actively encourage role-based policy, this silently breaks it for any delegated work.
  2. IM/internal-auth runs: inject early-returns for INTERNAL_SYSTEM_ROLE, so channel-originated runs also get user_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"),

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.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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)

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.

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"

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

Comment thread backend/docs/GUARDRAILS.md Outdated
return decision

async def aevaluate(self, request):
return self.evaluate(request)

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

@WillemJiang WillemJiang added the reviewing A maintainer is reviewing this PR label Jun 20, 2026
@github-actions github-actions Bot added the area:agents Agents, subagents, graph wiring, prompts, langgraph.json label Jun 20, 2026
@Miracle778

Miracle778 commented Jun 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. I went through the points, pushed a follow-up update, and verified the subagent scenario locally.

  1. Subagent tool-call attribution

Fixed. The delegated tool-call path now propagates runtime attribution from the parent task invocation into SubagentExecutor, and then into the subagent runtime context.

I also added is_subagent=true, so guardrail providers and audit sinks can distinguish subagent tool calls from lead-agent tool calls.

Added regression coverage for both layers:

  • subagent astream(..., context=...) receives the propagated attribution;
  • GuardrailMiddleware converts runtime context into GuardrailRequest fields.

Verification:

I also verified the role-aware subagent scenario locally. To make the UI scenario reliably trigger a subagent-owned bash call, I configured a local test subagent that only exposes bash:

subagents:
  custom_agents:
    guardrail-bash-test:
      description: "A test subagent that must run one bash command for guardrail attribution testing"
      system_prompt: |
        You are a test subagent. When delegated a task, call bash exactly as instructed.
      tools: ["bash"]
      disallowed_tools: ["task", "ask_clarification", "present_files"]
      model: "inherit"
      max_turns: 20
      timeout_seconds: 300

Verification result:

Admin user + subagent bash call -> allow-admin-bash
截屏2026-06-20 21 23 45

Regular user + subagent bash call -> deny-user-bash
截屏2026-06-20 21 28 45

Audit records include user_role, role_tool_key, run_id, tool_call_id, command_preview, and is_subagent: true.

截屏2026-06-20 21 32 54
  1. IM / internal-auth runs

I verified from the source that inject_authenticated_user_context() intentionally returns early for INTERNAL_SYSTEM_ROLE. This is existing behavior in the gateway identity model.

I did not change that path in this PR because resolving the real channel owner's role would require a broader internal-auth / channel ownership / user lookup design, which is outside the scope of this PR.

I documented it as a known limitation: normal authenticated Web/API runs carry server-side user attribution, while internal-auth runs may still have user_role / oauth_* as None.

  1. Spoofing test coverage

Updated the spoofing coverage. The original body.context test no longer claims to cover full identity spoofing, because body.context is filtered before it reaches runtime context and user_role / oauth_* do not actually enter that path. It is now narrowed to the behavior it really covers: a legacy/non-Web body.context.user_id can be supplied, but server-side authenticated user context still wins.

The full spoofing regression now uses the real risk path through body.config.context. That path is copied wholesale by build_run_config(), so spoofed user_id, user_role, oauth_provider, and oauth_id actually reach config["context"]; the test verifies that inject_authenticated_user_context() overwrites them with server-side authenticated user values.

  1. Async provider example

Updated the docs example so async evaluation does not perform blocking file I/O directly on the event loop.

The example keeps policy/audit behavior provider-defined while showing an async-safe pattern.

@WillemJiang
WillemJiang merged commit 5a699e2 into bytedance:main Jun 21, 2026
14 checks passed
yogyoho added a commit to yogyoho/eai-flow-main that referenced this pull request Jul 3, 2026
5a699e2 executor GuardrailRequest 加 user_id/user_role/run_id/oauth 字段
task_tool 传递 parent runtime context 到子代理, GuardrailMiddleware 可做 role-aware 鉴权
3处冲突均HEAD空+上游新增(纯增量), sed取上游
yogyoho added a commit to yogyoho/eai-flow-main that referenced this pull request Jul 3, 2026
…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 空+上游新增
marvin9551 pushed a commit to marvin9551/deer-flow that referenced this pull request Aug 21, 2026
…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>
jihtsan pushed a commit to jihtsan/dnx-deer-flow that referenced this pull request Aug 29, 2026
…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>
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 needs-validation Touches front/back contract surface; needs real-path validation reviewing A maintainer is reviewing this PR 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.

[feat] 为 GuardrailRequest 补充用户身份与运行时归因字段

5 participants