fix(sandbox): keep the exit marker last when appending audit warnings (#6307)

* fix(sandbox): keep the exit marker last when appending audit warnings

The medium-risk warning was appended after the bash exit marker and the ToolMessage was rebuilt from four fields. The subagent executor anchors its exit-status regex on the trailing 'Exit Code: N' (and fullmatches 'Command exited with code N'), so a failed command was harvested as success; the rebuild also dropped deerflow_tool_meta, artifact and id. Insert the warning before a trailing marker, leave a bare remote marker untouched, and copy the message instead of rebuilding it.

Document the warning placement in agents/middlewares/AGENTS.md.

* docs(changelog): reference #6307 in the sandbox audit entry

* fix(sandbox): keep a trailing remote exit marker last as well

Match the full sandbox.tools._BASH_EXIT_MARKER_TAIL_RE alternation when
choosing where to insert the medium-risk warning, so output that ends in
`Command exited with code N` after other text keeps the marker last too.

* test(sandbox): import the middleware tail regex inside its test

A module-level import of the new name made the whole file fail to collect
against main, hiding which tests the fix turns green.
This commit is contained in:
wgwfn3 2026-10-05 19:36:05 +08:00 committed by GitHub
parent fcee85b822
commit 7d50f3fc7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 147 additions and 9 deletions

View File

@ -466,6 +466,15 @@ This release closes that milestone with **301 merged pull requests**.
### Fixed
- **sandbox:** Medium-risk audit warnings no longer hide a failed shell exit from
subagent evidence. `SandboxAuditMiddleware` appended its warning after the
trailing `Exit Code: N` marker and rebuilt the `ToolMessage` from four fields,
so `_bash_evidence_status` could not find the marker and fell back to
`deerflow_tool_meta`, which reports `success`; a failed `sudo pytest -q` could
satisfy a `tests_passed` acceptance criterion. The warning is now inserted
before a trailing `Exit Code: N` or `Command exited with code N`, an output
that is only `Command exited with code N` is left unchanged, and the result
keeps `deerflow_tool_meta`, `artifact` and `id`. ([#6307])
- **persistence:** A second Gateway instance no longer fails startup with
`TimeoutError` while another instance runs a PostgreSQL schema migration. The
bootstrap advisory lock was taken with a blocking `pg_advisory_lock` on the
@ -7756,3 +7765,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6282]: https://github.com/bytedance/deer-flow/pull/6282
[#6305]: https://github.com/bytedance/deer-flow/pull/6305
[#6306]: https://github.com/bytedance/deer-flow/pull/6306
[#6307]: https://github.com/bytedance/deer-flow/pull/6307

View File

@ -423,6 +423,13 @@
### 修复
- **沙箱:** 中风险审计警告不再让子智能体证据丢失失败的 shell 退出码。
`SandboxAuditMiddleware` 原先把警告追加在结尾的 `Exit Code: N` 标记之后,并只用
四个字段重建 `ToolMessage`,导致 `_bash_evidence_status` 找不到标记,退回到报告
`success` 的 `deerflow_tool_meta`,失败的 `sudo pytest -q` 可能满足 `tests_passed`
验收条件。现在警告插在结尾的 `Exit Code: N` 或 `Command exited with code N`
之前,整段只有 `Command exited with code N` 的输出保持原样,结果保留
`deerflow_tool_meta`、`artifact` 与 `id`。([#6307])
- **持久化:** 另一个实例正在执行 PostgreSQL 模式迁移时,第二个 Gateway 实例不再
因 `TimeoutError` 启动失败。引导期 advisory lock 此前在应用引擎上以阻塞的
`pg_advisory_lock` 获取,而该引擎的 asyncpg `database.command_timeout`(默认
@ -6470,3 +6477,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
[#6282]: https://github.com/bytedance/deer-flow/pull/6282
[#6305]: https://github.com/bytedance/deer-flow/pull/6305
[#6306]: https://github.com/bytedance/deer-flow/pull/6306
[#6307]: https://github.com/bytedance/deer-flow/pull/6307

View File

@ -75,10 +75,10 @@ strict providers reject.
`__authorization_outcome` key (so `build_run_config` strips caller-supplied
forgeries). Consumers pop it; the publisher and the consumer share only that
contract module.
12. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations before tool execution; command classification is **defense-in-depth and audit, not a security boundary** (the sandbox is the isolation boundary). Command substitution is judged by *position*, not the presence of `$(`: **command position** (`$(curl url)`, `` `curl url` ``, the word after `|`/`&&`/`;`, an `eval`/`source` argument) executes fetched content and is blocked; **value position** (`x=$(curl url)`, `echo $(curl url)`, an argument, a `for` word list) only captures output and passes (#4611). So `_HIGH_RISK_COMMAND_POSITION_PATTERNS` is matched anchored against each sub-command from `_split_compound_command(split_pipes=True)`, never the whole string; pipe-spanning rules (`| sh`, `base64 -d | ...`) still use `_classify_command`'s whole-command Pass 1. `_COMMAND_POSITION_PREFIX` extends the anchor over leading assignments and exec wrappers (`FOO=1 $(curl url)`, `env`/`command`/`builtin`/`exec`/`nohup`/`time`/`sudo`/`doas`); its assignment branch requires whitespace before the substitution, which keeps `x=$(curl url)` in value position. Two contexts are deliberately **position-blind** (matched whole-command in Pass 1, since they execute their input anywhere, e.g. `xargs sh -c "$(curl url)"`): an `eval`/`source` argument, and an interpreter **code-string flag** — `-c` (shells, `python`), `-e` (`perl`/`ruby`/`node`), `-p` (`perl`/`node`), `-r` (`php`) — plus the here-string (`<<<`) reaching the same place via stdin. All three substitution spellings (`$(`, `<(`, `` ` ``) share one `_RISKY_SUBSTITUTION` opener. An unquoted newline splits like `;` (else `echo hi\n$(curl url)` evades the anchored rules). Heredoc bodies are data: `_split_compound_command` records headers (`<<EOF`, `<<-EOF`, `<<'EOF'`) and consumes their bodies verbatim, so a body line starting `$(curl url)` isn't promoted to a command position; `<<<` (here-string, needs look-ahead + look-behind) and a `<<` inside `$(( ))`/`(( ))` (bit shift, arithmetic depth tracked with the quote flags) must not open one. This is a heuristic, not shell parsing — an unterminated body consumes the rest of the string, an unclosed `((` only disables heredoc detection, and the failure direction is always toward *more* command positions, not fewer. Known gaps: process substitution outside `eval`/`source` (`. <(curl u)`) is undetected, and two-step forms (`x=$(curl u); eval "$x"`) need dataflow analysis. No config gate — appended unconditionally in `_build_runtime_middlewares`, for both lead and subagents.
12. **SandboxAuditMiddleware** - Audits sandboxed shell/file operations before tool execution; command classification is **defense-in-depth and audit, not a security boundary** (the sandbox is the isolation boundary). Command substitution is judged by *position*, not the presence of `$(`: **command position** (`$(curl url)`, `` `curl url` ``, the word after `|`/`&&`/`;`, an `eval`/`source` argument) executes fetched content and is blocked; **value position** (`x=$(curl url)`, `echo $(curl url)`, an argument, a `for` word list) only captures output and passes (#4611). So `_HIGH_RISK_COMMAND_POSITION_PATTERNS` is matched anchored against each sub-command from `_split_compound_command(split_pipes=True)`, never the whole string; pipe-spanning rules (`| sh`, `base64 -d | ...`) still use `_classify_command`'s whole-command Pass 1. `_COMMAND_POSITION_PREFIX` extends the anchor over leading assignments and exec wrappers (`FOO=1 $(curl url)`, `env`/`command`/`builtin`/`exec`/`nohup`/`time`/`sudo`/`doas`); its assignment branch requires whitespace before the substitution, which keeps `x=$(curl url)` in value position. Two contexts are deliberately **position-blind** (matched whole-command in Pass 1, since they execute their input anywhere, e.g. `xargs sh -c "$(curl url)"`): an `eval`/`source` argument, and an interpreter **code-string flag** — `-c` (shells, `python`), `-e` (`perl`/`ruby`/`node`), `-p` (`perl`/`node`), `-r` (`php`) — plus the here-string (`<<<`) reaching the same place via stdin. All three substitution spellings (`$(`, `<(`, `` ` ``) share one `_RISKY_SUBSTITUTION` opener. An unquoted newline splits like `;` (else `echo hi\n$(curl url)` evades the anchored rules). Heredoc bodies are data: `_split_compound_command` records headers (`<<EOF`, `<<-EOF`, `<<'EOF'`) and consumes their bodies verbatim, so a body line starting `$(curl url)` isn't promoted to a command position; `<<<` (here-string, needs look-ahead + look-behind) and a `<<` inside `$(( ))`/`(( ))` (bit shift, arithmetic depth tracked with the quote flags) must not open one. This is a heuristic, not shell parsing — an unterminated body consumes the rest of the string, an unclosed `((` only disables heredoc detection, and the failure direction is always toward *more* command positions, not fewer. Known gaps: process substitution outside `eval`/`source` (`. <(curl u)`) is undetected, and two-step forms (`x=$(curl u); eval "$x"`) need dataflow analysis. Medium-risk warnings precede a trailing exit marker and keep `deerflow_tool_meta`. No config gate — appended unconditionally in `_build_runtime_middlewares`, for both lead and subagents.
13. **ReadBeforeWriteMiddleware** - *(optional, `read_before_write.enabled`, default on)* Outermost write gate (#3857): `read_file` stamps a content hash on its ToolMessage; `write_file` (existing file, incl. append) and `str_replace` are blocked unless the newest mark for the path matches its current hash. Sits outside ToolProgress/ToolErrorHandling (a block consumes no ToolProgress slot); blocked results self-stamp `deerflow_tool_meta` and carry `deerflow_write_block` (`{path, tool}`). Marks live on messages, so summarization dropping the read invalidates the gate; writes never refresh marks. Gate check + execution are serialized per (thread, path); `"Error: ..."`-string sandboxes (AIO/E2B) fail open. It owns the composed call's sandbox authorization scope; `SandboxAuthorizationError` becomes an error ToolMessage. Its `wrap_model_call` swaps blocked calls' dead payload (`content`, `old_str`/`new_str`) for a deterministic placeholder in the model-bound request only (`elide_blocked_payloads`, `elide_min_chars`); state, receipts, and the journal keep the originals. Policy stays in the gate; the shared `tool_call_args` helper rewrites every arg surface together and every model-bound arg rewrite must use it.
14. **ToolProgressMiddleware** - *(optional, if `tool_progress.enabled`)* State-machine-based stagnation guard (RFC #3177). Outer wrapper around ToolErrorHandlingMiddleware so its tool wrapper receives results already stamped with `deerflow_tool_meta`. Recoverable problems stay WARNED with hints; retryable non-recoverable problems escalate to BLOCKED; stop-category failures block immediately. It is independent of LoopDetection's turn-level call-pattern guard. Effective phase changes emit `middleware:tool_progress`, including `reset` when a later agent invocation (for example, goal continuation) clears WARNED/BLOCKED state. The state machine supplies the action and count threshold (`null` for category rules, recovery, and reset); recorder calls happen after releasing the state lock. Events include the actual sync/async hook plus bounded status, error, and recovery fields. Server-owned recorder keys determine subagent attribution. Arguments, content, prompts, and hashes are never persisted; recorder failures are fail-open. See [event semantics](../../../../../docs/RUN_EVENT_STREAM.md).
15. **ToolReceiptMiddleware + ToolErrorHandlingMiddleware** - `ToolReceiptMiddleware` is *(optional, if `verification.receipts_enabled`, default on)*. It is the **outermost `wrap_tool_call` layer** — registered ahead of authorization, audit, write and progress gates — because Guardrail/SandboxAudit/ReadBeforeWrite/ToolProgress can short-circuit a call with their own ToolMessage (and SandboxAudit rebuilds medium-risk results); an inner receipt layer would silently gap the ledger on those results (ordering constraints in `deerflow.extensions.ordering`). Normal results still carry the `deerflow_tool_meta` status ToolErrorHandlingMiddleware stamps on the inner return path; short-circuit messages self-stamp meta or fall back to `message.status`. It stamps deterministic provenance (tool name, status, args/output hashes, byte count, timestamp) onto direct `ToolMessage` results and every matching `ToolMessage` carried in `Command.update.messages`, including delegated `task`, `present_file`, `view_image`, and `tool_search` results; before model calls it derives a hidden receipt ledger (display ids r1..rN) from message state, and when the 2,000-character budget is exceeded the newest receipts are retained in chronological order with their original ids plus an older-receipts omission marker. Rendering returns both the text and its retained receipt subset; every response that received a ledger carries only that exact server-owned subset, never omitted receipts. Snapshot validation accepts a strictly consecutive positive original-id range (for example `r24`–`r30`) rather than requiring `r1`, so subagent terminal citation verification resolves ids against evidence present in the citing turn even when later summarization drops and renumbers tool messages. Model-generated citation IDs are digit-bounded before integer conversion; oversized IDs are ignored as malformed input rather than raising through task write-back. Citation parsing deduplicates exact `(id, anchor)` pairs, not IDs alone, so repeated identical references stay compact while every distinct anchor claim is verified. Gateway strips delegated receipts/verdicts from external messages. `ToolErrorHandlingMiddleware` receives `AppConfig`, converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting, stamps every result with `deerflow_tool_meta` (status / error_type / recoverable_by_model / recommended_next_action / source) via `tool_result_meta.normalize_tool_result`, stamps structured metadata for task exception wrappers, and stamps skill-read metadata for downstream durable-context capture. Task tool result text is generated from the same status/result/error inputs as the structured metadata so callers do not hand-write a second protocol string.
15. **ToolReceiptMiddleware + ToolErrorHandlingMiddleware** - `ToolReceiptMiddleware` is *(optional, if `verification.receipts_enabled`, default on)*. It is the **outermost `wrap_tool_call` layer** — registered ahead of authorization, audit, write and progress gates — because Guardrail/SandboxAudit/ReadBeforeWrite/ToolProgress can short-circuit a call with their own ToolMessage (and SandboxAudit annotates medium-risk results); an inner receipt layer would silently gap the ledger on those results (ordering constraints in `deerflow.extensions.ordering`). Normal results still carry the `deerflow_tool_meta` status ToolErrorHandlingMiddleware stamps on the inner return path; short-circuit messages self-stamp meta or fall back to `message.status`. It stamps deterministic provenance (tool name, status, args/output hashes, byte count, timestamp) onto direct `ToolMessage` results and every matching `ToolMessage` carried in `Command.update.messages`, including delegated `task`, `present_file`, `view_image`, and `tool_search` results; before model calls it derives a hidden receipt ledger (display ids r1..rN) from message state, and when the 2,000-character budget is exceeded the newest receipts are retained in chronological order with their original ids plus an older-receipts omission marker. Rendering returns both the text and its retained receipt subset; every response that received a ledger carries only that exact server-owned subset, never omitted receipts. Snapshot validation accepts a strictly consecutive positive original-id range (for example `r24`–`r30`) rather than requiring `r1`, so subagent terminal citation verification resolves ids against evidence present in the citing turn even when later summarization drops and renumbers tool messages. Model-generated citation IDs are digit-bounded before integer conversion; oversized IDs are ignored as malformed input rather than raising through task write-back. Citation parsing deduplicates exact `(id, anchor)` pairs, not IDs alone, so repeated identical references stay compact while every distinct anchor claim is verified. Gateway strips delegated receipts/verdicts from external messages. `ToolErrorHandlingMiddleware` receives `AppConfig`, converts tool exceptions into error `ToolMessage`s so the run can continue instead of aborting, stamps every result with `deerflow_tool_meta` (status / error_type / recoverable_by_model / recommended_next_action / source) via `tool_result_meta.normalize_tool_result`, stamps structured metadata for task exception wrappers, and stamps skill-read metadata for downstream durable-context capture. Task tool result text is generated from the same status/result/error inputs as the structured metadata so callers do not hand-write a second protocol string.
16. **ArtifactCaptureMiddleware** - See [tool artifacts](TOOL_ARTIFACTS.md).
Authorization identity is independent of enforcement. Gateway strips client identity overrides: only the server auth source sets `is_internal`, and only authenticated IM `body.context` supplies `channel_user_id` (never `body.config`). `build_principal_from_context` applies role defaults, strict provenance, and copied attributes; RBAC rejects unknown defaults. Delegation and `GuardrailMiddleware` share this identity. Layer 1 precedes deferred assembly across agent paths and its provider is reused for Layer 2; framework skill/memory ordering stays stable. Trusted `DeerFlowClient.stream()` accepts identity overrides. Its graph key always includes effective storage `user_id` and, when enforced, the full Principal; nested attributes are copied so mutation cannot hide stale cache state.

View File

@ -114,6 +114,14 @@ _MEDIUM_RISK_PATTERNS: list[re.Pattern[str]] = [
re.compile(r"\bPATH\s*="),
]
# Shell exit markers at the end of the output (mirrors
# ``sandbox.tools._BASH_EXIT_MARKER_TAIL_RE``, plus a bare ``Exit Code: N``):
# local ``Exit Code: N`` and remote ``Command exited with code N``. Remote
# providers emit the latter as the whole output; it is matched after other
# output too, so the warning never lands after either form.
_EXIT_MARKER_TAIL_RE = re.compile(r"(?:(?:^|\n)Exit Code: -?\d+|\n?Command exited with code -?\d+)\s*$")
_SILENT_EXIT_MARKER_RE = re.compile(r"Command exited with code -?\d+")
# A heredoc header and its delimiter: ``<<EOF``, ``<< EOF``, ``<<-EOF``,
# ``<<\EOF``, ``<<'EOF'``, ``<<"EOF"``. Both guards are needed to keep ``<<<``
@ -427,13 +435,18 @@ class SandboxAuditMiddleware(AgentMiddleware[ThreadState]):
if isinstance(result.content, list):
new_content = list(result.content) + [{"type": "text", "text": warning}]
else:
new_content = str(result.content) + warning
return ToolMessage(
content=new_content,
tool_call_id=result.tool_call_id,
name=result.name,
status=result.status,
)
content = str(result.content)
if _SILENT_EXIT_MARKER_RE.fullmatch(content.strip()):
# The whole output IS the exit marker; evidence consumers
# fullmatch it, so any added text would erase the failure.
return result
# Keep a trailing exit marker last: evidence consumers anchor on
# it to recover the real shell status (see sandbox/tools.py).
marker = _EXIT_MARKER_TAIL_RE.search(content)
cut = marker.start() if marker else len(content)
new_content = content[:cut] + warning + content[cut:]
# model_copy keeps additional_kwargs (deerflow_tool_meta), artifact and id.
return result.model_copy(update={"content": new_content})
# ------------------------------------------------------------------
# Input sanitisation

View File

@ -12,6 +12,7 @@ from deerflow.agents.middlewares.sandbox_audit_middleware import (
_classify_command,
_split_compound_command,
)
from deerflow.sandbox.tools import _BASH_EXIT_MARKER_TAIL_RE
# ---------------------------------------------------------------------------
# Helpers
@ -744,6 +745,112 @@ class TestSandboxAuditMiddlewareWrapToolCall:
assert verdict == "warn"
# ---------------------------------------------------------------------------
# Medium-risk warning must not disturb the evidence the subagent harvests
# ---------------------------------------------------------------------------
class TestMediumRiskWarningPreservesEvidence:
"""The warning is appended to a result that ToolErrorHandling has already
stamped; the subagent then reads the exit status from a trailing marker
and ``deerflow_tool_meta``. Neither may be lost to the warning."""
def setup_method(self):
self.mw = SandboxAuditMiddleware()
def _call(self, inner: ToolMessage, command: str = "sudo pytest -q") -> ToolMessage:
with patch.object(self.mw, "_write_audit"):
return self.mw.wrap_tool_call(_make_request(command), _make_handler(inner))
def test_exit_marker_stays_at_the_end(self):
inner = ToolMessage(content="12 passed in 1.0s\nExit Code: 1", tool_call_id="call-123", name="bash")
result = self._call(inner)
assert "warning" in result.content.lower()
assert result.content.endswith("\nExit Code: 1")
assert result.content.startswith("12 passed in 1.0s")
def test_bare_exit_marker_is_kept(self):
inner = ToolMessage(content="Exit Code: 2", tool_call_id="call-123", name="bash")
assert self._call(inner).content.endswith("Exit Code: 2")
def test_exit_marker_with_trailing_whitespace_stays_at_the_end(self):
inner = ToolMessage(content="boom\nExit Code: -9 \n", tool_call_id="call-123", name="bash")
result = self._call(inner)
assert "warning" in result.content.lower()
assert result.content.endswith("\nExit Code: -9 \n")
def test_silent_remote_exit_marker_is_left_untouched(self):
"""Remote providers emit this as the whole output; consumers fullmatch it."""
inner = ToolMessage(content="Command exited with code 3", tool_call_id="call-123", name="bash")
assert self._call(inner).content == "Command exited with code 3"
def test_remote_exit_marker_after_output_stays_at_the_end(self):
"""Same shape ``sandbox.tools._BASH_EXIT_MARKER_TAIL_RE`` preserves on truncation."""
inner = ToolMessage(content="progress...\nCommand exited with code 3", tool_call_id="call-123", name="bash")
result = self._call(inner)
assert result.content.startswith("progress...")
assert "warning" in result.content.lower()
assert result.content.endswith("\nCommand exited with code 3")
assert result.content.count("Command exited with code 3") == 1
def test_exit_marker_regex_covers_the_truncation_tail_shapes(self):
"""Every tail shape truncation keeps last is one the warning is inserted before."""
from deerflow.agents.middlewares.sandbox_audit_middleware import _EXIT_MARKER_TAIL_RE
for content in ("out\nExit Code: 1", "out\nExit Code: -9 \n", "out\nCommand exited with code 3", "Command exited with code 3"):
ours = _EXIT_MARKER_TAIL_RE.search(content)
theirs = _BASH_EXIT_MARKER_TAIL_RE.search(content)
assert ours is not None and theirs is not None
assert ours.start() == theirs.start(), content
def test_output_without_marker_still_gets_warning_at_the_end(self):
inner = ToolMessage(content="Successfully installed requests", tool_call_id="call-123", name="bash")
result = self._call(inner, "pip install requests")
assert result.content.startswith("Successfully installed requests")
assert result.content.rstrip().endswith("may modify the runtime environment.")
def test_rebuild_keeps_tool_meta_artifact_and_id(self):
meta = {"status": "success", "source": "normalized"}
inner = ToolMessage(
content="ok\nExit Code: 0",
tool_call_id="call-123",
name="bash",
id="tool-msg-1",
artifact={"k": "v"},
additional_kwargs={"deerflow_tool_meta": meta},
)
result = self._call(inner)
assert result.additional_kwargs == {"deerflow_tool_meta": meta}
assert result.artifact == {"k": "v"}
assert result.id == "tool-msg-1"
assert result.tool_call_id == "call-123"
assert result.status == inner.status
def test_list_content_gets_warning_block_and_keeps_meta(self):
blocks = [{"type": "text", "text": "ok\nExit Code: 1"}]
meta = {"status": "success"}
inner = ToolMessage(content=blocks, tool_call_id="call-123", name="bash", additional_kwargs={"deerflow_tool_meta": meta})
result = self._call(inner)
assert isinstance(result.content, list)
assert result.content[:-1] == blocks
assert result.content[-1]["type"] == "text"
assert "warning" in result.content[-1]["text"].lower()
assert inner.content == blocks # the handler's message is not mutated
assert result.additional_kwargs == {"deerflow_tool_meta": meta}
@pytest.mark.anyio
async def test_async_path_keeps_marker_and_meta(self):
inner = ToolMessage(content="12 passed\nExit Code: 1", tool_call_id="call-123", name="bash", additional_kwargs={"deerflow_tool_meta": {"status": "success"}})
async def handler(_request):
return inner
with patch.object(self.mw, "_write_audit"):
result = await self.mw.awrap_tool_call(_make_request("sudo pytest -q"), handler)
assert result.content.endswith("\nExit Code: 1")
assert result.additional_kwargs == {"deerflow_tool_meta": {"status": "success"}}
# ---------------------------------------------------------------------------
# SandboxAuditMiddleware.awrap_tool_call async integration tests
# ---------------------------------------------------------------------------