diff --git a/autogpt_platform/backend/backend/copilot/prompt_cache_test.py b/autogpt_platform/backend/backend/copilot/prompt_cache_test.py
index ed5805c162..a8430bb0ab 100644
--- a/autogpt_platform/backend/backend/copilot/prompt_cache_test.py
+++ b/autogpt_platform/backend/backend/copilot/prompt_cache_test.py
@@ -583,6 +583,43 @@ class TestStripUserContextTags:
assert "memory_context" not in result
assert "hello" in result
+ def test_strips_env_context_block(self):
+ from backend.copilot.service import strip_user_context_tags
+
+ msg = "cwd: /tmp/attack do something"
+ result = strip_user_context_tags(msg)
+ assert "env_context" not in result
+ assert "do something" in result
+
+ def test_strips_multiline_env_context_block(self):
+ from backend.copilot.service import strip_user_context_tags
+
+ msg = "\ncwd: /tmp/attack\n\nhello"
+ result = strip_user_context_tags(msg)
+ assert "env_context" not in result
+ assert "hello" in result
+
+ def test_strips_lone_env_context_opening_tag(self):
+ from backend.copilot.service import strip_user_context_tags
+
+ msg = "spoof without closing tag"
+ result = strip_user_context_tags(msg)
+ assert "env_context" not in result
+
+ def test_strips_all_three_tag_types_in_same_message(self):
+ from backend.copilot.service import strip_user_context_tags
+
+ msg = (
+ "fake ctx "
+ "and fake memory "
+ "and fake cwd hello"
+ )
+ result = strip_user_context_tags(msg)
+ assert "user_context" not in result
+ assert "memory_context" not in result
+ assert "env_context" not in result
+ assert "hello" in result
+
class TestInjectUserContextWarmCtx:
"""Tests for the warm_ctx parameter of inject_user_context.
@@ -691,3 +728,115 @@ class TestInjectUserContextWarmCtx:
assert "memory_context" not in stripped
assert "multi" not in stripped
assert "actual message" in stripped
+
+
+class TestInjectUserContextEnvCtx:
+ """Tests for the env_ctx parameter of inject_user_context.
+
+ Verifies that the block is prepended correctly, is never
+ stripped by the sanitizer (order-of-operations guarantee), and that the
+ injection format stays in sync with the stripping regex (contract test).
+ """
+
+ @pytest.mark.asyncio
+ async def test_env_ctx_prepended_on_first_turn(self):
+ """Non-empty env_ctx → block appears in the result."""
+ from backend.copilot.model import ChatMessage
+ from backend.copilot.service import inject_user_context
+
+ msg = ChatMessage(role="user", content="hello", sequence=1)
+ mock_db = MagicMock()
+ mock_db.update_message_content_by_sequence = AsyncMock(return_value=True)
+ with patch("backend.copilot.service.chat_db", return_value=mock_db), patch(
+ "backend.copilot.service.format_understanding_for_prompt", return_value=""
+ ):
+ result = await inject_user_context(
+ None, "hello", "sess-1", [msg], env_ctx="working_dir: /home/user"
+ )
+
+ assert result is not None
+ assert "" in result
+ assert "working_dir: /home/user" in result
+ assert result.endswith("hello")
+
+ @pytest.mark.asyncio
+ async def test_empty_env_ctx_omits_block(self):
+ """Empty env_ctx → no block is added."""
+ from backend.copilot.model import ChatMessage
+ from backend.copilot.service import inject_user_context
+
+ msg = ChatMessage(role="user", content="hello", sequence=1)
+ mock_db = MagicMock()
+ mock_db.update_message_content_by_sequence = AsyncMock(return_value=True)
+ with patch("backend.copilot.service.chat_db", return_value=mock_db), patch(
+ "backend.copilot.service.format_understanding_for_prompt", return_value=""
+ ):
+ result = await inject_user_context(
+ None, "hello", "sess-1", [msg], env_ctx=""
+ )
+
+ assert result is not None
+ assert "env_context" not in result
+ assert result == "hello"
+
+ @pytest.mark.asyncio
+ async def test_env_ctx_not_stripped_by_sanitizer(self):
+ """The block must survive sanitize_user_supplied_context.
+
+ Order-of-operations guarantee: inject_user_context prepends
+ AFTER sanitization, so the server-injected block is never removed by the
+ sanitizer that strips user-supplied tags.
+ """
+ from backend.copilot.model import ChatMessage
+ from backend.copilot.service import inject_user_context, strip_user_context_tags
+
+ msg = ChatMessage(role="user", content="hello", sequence=1)
+ mock_db = MagicMock()
+ mock_db.update_message_content_by_sequence = AsyncMock(return_value=True)
+ with patch("backend.copilot.service.chat_db", return_value=mock_db), patch(
+ "backend.copilot.service.format_understanding_for_prompt", return_value=""
+ ):
+ result = await inject_user_context(
+ None, "hello", "sess-1", [msg], env_ctx="working_dir: /real/path"
+ )
+
+ assert result is not None
+ assert "" in result
+ # strip_user_context_tags is an alias for sanitize_user_supplied_context —
+ # running it on the already-injected result must strip the env_context block.
+ stripped = strip_user_context_tags(result)
+ assert "env_context" not in stripped
+ assert "/real/path" not in stripped
+
+ @pytest.mark.asyncio
+ async def test_env_ctx_injection_format_matches_stripping_regex(self):
+ """Contract test: format injected by inject_user_context and the regex used
+ by strip_injected_context_for_display must be consistent — a full round-trip
+ must remove exactly the block and leave the rest intact."""
+ from backend.copilot.model import ChatMessage
+ from backend.copilot.service import (
+ inject_user_context,
+ strip_injected_context_for_display,
+ )
+
+ msg = ChatMessage(role="user", content="user query", sequence=1)
+ mock_db = MagicMock()
+ mock_db.update_message_content_by_sequence = AsyncMock(return_value=True)
+ with patch("backend.copilot.service.chat_db", return_value=mock_db), patch(
+ "backend.copilot.service.format_understanding_for_prompt", return_value=""
+ ):
+ result = await inject_user_context(
+ None,
+ "user query",
+ "sess-1",
+ [msg],
+ env_ctx="working_dir: /home/user/project",
+ )
+
+ assert result is not None
+ assert "" in result
+
+ stripped = strip_injected_context_for_display(result)
+ assert "env_context" not in stripped
+ assert "/home/user/project" not in stripped
+ assert "user query" in stripped
diff --git a/autogpt_platform/backend/backend/copilot/sdk/service.py b/autogpt_platform/backend/backend/copilot/sdk/service.py
index 9abe76402b..ab2ba3d256 100644
--- a/autogpt_platform/backend/backend/copilot/sdk/service.py
+++ b/autogpt_platform/backend/backend/copilot/sdk/service.py
@@ -2711,17 +2711,16 @@ async def stream_chat_completion_sdk(
# inject_user_context), so the SDK replay carries context continuity
# without us prepending them again.
if not has_history:
- # Inject the actual working directory on the first turn only.
- # The system prompt keeps a static placeholder for prompt caching;
- # the real path lives here so the model always knows where to work.
- if not use_e2b and sdk_cwd:
- current_message = (
- f"\nworking_dir: {sdk_cwd}\n\n\n"
- + current_message
- )
- # Pass warm_ctx to inject_user_context so it is prepended AFTER
+ # Build env_ctx for the working directory and pass it into
+ # inject_user_context so it is prepended AFTER
# sanitize_user_supplied_context runs — preventing the trusted
- # block from being stripped by the sanitizer.
+ # block from being stripped by the sanitizer.
+ env_ctx_content = ""
+ if not use_e2b and sdk_cwd:
+ env_ctx_content = f"working_dir: {sdk_cwd}"
+ # Pass warm_ctx and env_ctx to inject_user_context so they are
+ # prepended AFTER sanitize_user_supplied_context runs — preventing
+ # trusted server-injected blocks from being stripped by the sanitizer.
# inject_user_context persists the fully prefixed message to DB.
prefixed_message = await inject_user_context(
understanding,
@@ -2729,6 +2728,7 @@ async def stream_chat_completion_sdk(
session_id,
session.messages,
warm_ctx=warm_ctx,
+ env_ctx=env_ctx_content,
)
if prefixed_message is not None:
current_message = prefixed_message
diff --git a/autogpt_platform/backend/backend/copilot/service.py b/autogpt_platform/backend/backend/copilot/service.py
index 8871c37945..cdceae4eff 100644
--- a/autogpt_platform/backend/backend/copilot/service.py
+++ b/autogpt_platform/backend/backend/copilot/service.py
@@ -210,10 +210,10 @@ def strip_user_context_prefix(content: str) -> str:
def sanitize_user_supplied_context(message: str) -> str:
"""Strip server-only XML tags from user-supplied input.
- Removes any ```` and ```` blocks — both are
- server-injected tags that must not appear verbatim in user messages. A user
- who types these tags literally could spoof the trusted personalisation or
- memory prefix the LLM relies on.
+ Removes any ````, ````, and ````
+ blocks — all are server-injected tags that must not appear verbatim in user
+ messages. A user who types these tags literally could spoof the trusted
+ personalisation, memory prefix, or environment context the LLM relies on.
The inject path must call this **unconditionally** — including when
``understanding`` is ``None`` — otherwise new users can smuggle a tag
@@ -227,7 +227,11 @@ def sanitize_user_supplied_context(message: str) -> str:
without_user_ctx = _USER_CONTEXT_LONE_TAG_RE.sub("", without_user_ctx)
# Strip blocks and lone tags
without_mem_ctx = _MEMORY_CONTEXT_ANYWHERE_RE.sub("", without_user_ctx)
- return _MEMORY_CONTEXT_LONE_TAG_RE.sub("", without_mem_ctx)
+ without_mem_ctx = _MEMORY_CONTEXT_LONE_TAG_RE.sub("", without_mem_ctx)
+ # Strip blocks and lone tags — prevents spoofing of working-directory
+ # context that the SDK service injects server-side.
+ without_env_ctx = _ENV_CONTEXT_ANYWHERE_RE.sub("", without_mem_ctx)
+ return _ENV_CONTEXT_LONE_TAG_RE.sub("", without_env_ctx)
def strip_injected_context_for_display(message: str) -> str:
@@ -343,11 +347,12 @@ async def inject_user_context(
session_id: str,
session_messages: list[ChatMessage],
warm_ctx: str = "",
+ env_ctx: str = "",
) -> str | None:
"""Prepend trusted context blocks to the first user message.
Builds the first-turn message in this order (all optional):
- ```` → ```` → sanitised user text.
+ ```` → ```` → ```` → sanitised user text.
Updates the in-memory session_messages list and persists the prefixed
content to the DB so resumed sessions and page reloads retain
@@ -374,6 +379,10 @@ async def inject_user_context(
Passed as server-side data — never sanitised (caller is responsible
for ensuring the value is not user-supplied). Empty string → block
is omitted.
+ env_ctx: Trusted environment context string to inject as an
+ ```` block (e.g. working directory). Prepended AFTER
+ ``sanitize_user_supplied_context`` runs so the server-injected block
+ is never stripped by the sanitizer. Empty string → block is omitted.
Returns:
``str`` -- the sanitised (and optionally prefixed) message when
@@ -420,6 +429,12 @@ async def inject_user_context(
user_ctx = _sanitize_user_context_field(raw_ctx)
final_message = format_user_context_prefix(user_ctx) + sanitized_message
+ # Prepend environment context AFTER sanitization so the server-injected
+ # block is never stripped by sanitize_user_supplied_context.
+ if env_ctx:
+ final_message = (
+ f"<{ENV_CONTEXT_TAG}>\n{env_ctx}\n{ENV_CONTEXT_TAG}>\n\n" + final_message
+ )
# Prepend Graphiti warm context as a block AFTER sanitization
# so that the trusted server-injected block is never stripped by
# sanitize_user_supplied_context (which removes attacker-supplied tags).