Skip to content

Commit e2db60d

Browse files
Merge branch 'main' into feat/ec2-fleet-strategy
2 parents 7860f86 + 396a245 commit e2db60d

23 files changed

Lines changed: 840 additions & 175 deletions

agent/src/memory.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import re
1313
import time
1414

15+
from sanitization import sanitize_external_content
16+
1517
_client = None
1618

1719
# Validates "owner/repo" format — must match the TypeScript-side isValidRepo pattern.
@@ -23,6 +25,10 @@
2325
# v3 = adds source_type provenance + content_sha256 integrity hash
2426
_SCHEMA_VERSION = "3"
2527

28+
# Valid source_type values for provenance tracking (schema v3).
29+
# Must stay in sync with MemorySourceType in cdk/src/handlers/shared/memory.ts.
30+
MEMORY_SOURCE_TYPES = frozenset({"agent_episode", "agent_learning", "orchestrator_fallback"})
31+
2632

2733
def _get_client():
2834
"""Lazy-init and cache the AgentCore client for memory operations."""
@@ -80,7 +86,7 @@ def write_task_episode(
8086
into the correct per-repo, per-task namespace.
8187
8288
Metadata includes source_type='agent_episode' for provenance tracking
83-
and content_sha256 for integrity verification on read (schema v3).
89+
and content_sha256 for integrity auditing on read (schema v3).
8490
8591
Returns True on success, False on failure (fail-open).
8692
"""
@@ -101,7 +107,10 @@ def write_task_episode(
101107
parts.append(f"Agent notes: {self_feedback}")
102108

103109
episode_text = " ".join(parts)
104-
content_hash = hashlib.sha256(episode_text.encode("utf-8")).hexdigest()
110+
# Hash the sanitized form; store the original. The read path re-sanitizes
111+
# and checks against this hash: sanitize(original) at write == sanitize(stored) at read.
112+
sanitized_text = sanitize_external_content(episode_text)
113+
content_hash = hashlib.sha256(sanitized_text.encode("utf-8")).hexdigest()
105114

106115
metadata = {
107116
"task_id": {"stringValue": task_id},
@@ -153,9 +162,10 @@ def write_repo_learnings(
153162
the correct per-repo namespace.
154163
155164
Metadata includes source_type='agent_learning' for provenance tracking
156-
and content_sha256 for integrity verification on read (schema v3).
157-
Note: hash verification only happens on the TS orchestrator read path
158-
(loadMemoryContext in memory.ts), not on the Python side.
165+
and content_sha256 for integrity auditing on read (schema v3).
166+
Note: hash auditing only happens on the TS orchestrator read path
167+
(loadMemoryContext in memory.ts) where mismatches are logged but
168+
records are kept — the Python side does not independently check hashes.
159169
160170
Returns True on success, False on failure (fail-open).
161171
"""
@@ -164,7 +174,10 @@ def write_repo_learnings(
164174
client = _get_client()
165175

166176
learnings_text = f"Repository learnings: {learnings}"
167-
content_hash = hashlib.sha256(learnings_text.encode("utf-8")).hexdigest()
177+
# Hash the sanitized form; store the original. The read path re-sanitizes
178+
# and checks against this hash: sanitize(original) at write == sanitize(stored) at read.
179+
sanitized_text = sanitize_external_content(learnings_text)
180+
content_hash = hashlib.sha256(sanitized_text.encode("utf-8")).hexdigest()
168181

169182
client.create_event(
170183
memoryId=memory_id,

agent/src/models.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
from enum import StrEnum
6-
from typing import Self
6+
from typing import Literal, Self
77

88
from pydantic import BaseModel, ConfigDict, Field, model_validator
99

@@ -52,6 +52,11 @@ class MemoryContext(BaseModel):
5252
past_episodes: list[str] = Field(default_factory=list)
5353

5454

55+
# Trust classification for content sources — mirrors ContentTrustLevel in context-hydration.ts.
56+
# 'trusted': user-supplied input, 'untrusted-external': GitHub-sourced content,
57+
# 'memory': memory records.
58+
ContentTrustLevel = Literal["trusted", "untrusted-external", "memory"]
59+
5560
# Bump when this agent supports a new orchestrator HydratedContext shape
5661
# (see cdk/src/handlers/shared/context-hydration.ts).
5762
SUPPORTED_HYDRATED_CONTEXT_VERSION = 1
@@ -73,6 +78,7 @@ class HydratedContext(BaseModel):
7378
guardrail_blocked: str | None = None
7479
resolved_branch_name: str | None = None
7580
resolved_base_branch: str | None = None
81+
content_trust: dict[str, ContentTrustLevel] | None = None
7682

7783
@model_validator(mode="after")
7884
def version_supported(self) -> Self:

agent/src/prompt_builder.py

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,53 +4,14 @@
44

55
import glob
66
import os
7-
import re
87
from typing import TYPE_CHECKING
98

109
from config import AGENT_WORKSPACE
1110
from prompts import get_system_prompt
11+
from sanitization import sanitize_external_content as sanitize_memory_content
1212
from shell import log
1313
from system_prompt import SYSTEM_PROMPT
1414

15-
# ---------------------------------------------------------------------------
16-
# Content sanitization for memory records
17-
# ---------------------------------------------------------------------------
18-
19-
_DANGEROUS_TAGS = re.compile(
20-
r"(<(script|style|iframe|object|embed|form|input)[^>]*>[\s\S]*?</\2>"
21-
r"|<(script|style|iframe|object|embed|form|input)[^>]*\/?>)",
22-
re.IGNORECASE,
23-
)
24-
_HTML_TAGS = re.compile(r"</?[a-z][^>]*>", re.IGNORECASE)
25-
_INSTRUCTION_PREFIXES = re.compile(
26-
r"^(SYSTEM|ASSISTANT|Human|Assistant)\s*:", re.MULTILINE | re.IGNORECASE
27-
)
28-
_INJECTION_PHRASES = re.compile(
29-
r"(?:ignore previous instructions|disregard (?:above|previous|all)|new instructions\s*:)",
30-
re.IGNORECASE,
31-
)
32-
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
33-
_BIDI_CHARS = re.compile(r"[\u200e\u200f\u202a-\u202e\u2066-\u2069]")
34-
_MISPLACED_BOM = re.compile(r"(?!^)\ufeff")
35-
36-
37-
def sanitize_memory_content(text: str | None) -> str:
38-
"""Sanitize memory content before injecting into the agent's system prompt.
39-
40-
Mirrors the TypeScript sanitizeExternalContent() in sanitization.ts.
41-
"""
42-
if not text:
43-
return text or ""
44-
s = _DANGEROUS_TAGS.sub("", text)
45-
s = _HTML_TAGS.sub("", s)
46-
s = _INSTRUCTION_PREFIXES.sub(r"[SANITIZED_PREFIX] \1:", s)
47-
s = _INJECTION_PHRASES.sub("[SANITIZED_INSTRUCTION]", s)
48-
s = _CONTROL_CHARS.sub("", s)
49-
s = _BIDI_CHARS.sub("", s)
50-
s = _MISPLACED_BOM.sub("", s)
51-
return s
52-
53-
5415
if TYPE_CHECKING:
5516
from models import HydratedContext, RepoSetup, TaskConfig
5617

agent/src/sanitization.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Content sanitization for external/untrusted inputs.
2+
3+
Mirrors the TypeScript sanitizeExternalContent() in
4+
cdk/src/handlers/shared/sanitization.ts. Both implementations
5+
must produce identical output for the same input — cross-language
6+
parity is verified by shared test fixtures.
7+
8+
Applied to: memory records (before hashing on write, before injection
9+
on read), GitHub issue/PR content (TS side only — Python agent receives
10+
already-sanitized content from the orchestrator's hydrated context).
11+
"""
12+
13+
import re
14+
15+
_DANGEROUS_TAGS = re.compile(
16+
r"(<(script|style|iframe|object|embed|form|input)[^>]*>[\s\S]*?</\2>"
17+
r"|<(script|style|iframe|object|embed|form|input)[^>]*\/?>)",
18+
re.IGNORECASE,
19+
)
20+
_HTML_TAGS = re.compile(r"</?[a-z][^>]*>", re.IGNORECASE)
21+
_INSTRUCTION_PREFIXES = re.compile(r"^(SYSTEM|ASSISTANT|Human)\s*:", re.MULTILINE | re.IGNORECASE)
22+
_INJECTION_PHRASES = re.compile(
23+
r"(?:ignore previous instructions|disregard (?:above|previous|all)|new instructions\s*:)",
24+
re.IGNORECASE,
25+
)
26+
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
27+
_BIDI_CHARS = re.compile(r"[\u200e\u200f\u202a-\u202e\u2066-\u2069]")
28+
_MISPLACED_BOM = re.compile(r"(?!^)\ufeff")
29+
30+
31+
def _strip_until_stable(s: str, pattern: re.Pattern[str]) -> str:
32+
"""Apply *pattern* repeatedly until the string stops changing.
33+
34+
A single pass can be bypassed by nesting fragments
35+
(e.g. "<scrip<script></script>t>" reassembles after inner tag removal).
36+
"""
37+
while True:
38+
prev = s
39+
s = pattern.sub("", s)
40+
if s == prev:
41+
return s
42+
43+
44+
def sanitize_external_content(text: str | None) -> str:
45+
"""Sanitize external content before it enters the agent's context.
46+
47+
Neutralizes rather than blocks — suspicious patterns are replaced with
48+
bracketed markers so content is still visible to the LLM (for legitimate
49+
discussion of prompts/instructions) but structurally defanged.
50+
"""
51+
if not text:
52+
return text or ""
53+
s = _strip_until_stable(text, _DANGEROUS_TAGS)
54+
s = _strip_until_stable(s, _HTML_TAGS)
55+
s = _INSTRUCTION_PREFIXES.sub(r"[SANITIZED_PREFIX] \1:", s)
56+
s = _INJECTION_PHRASES.sub("[SANITIZED_INSTRUCTION]", s)
57+
s = _CONTROL_CHARS.sub("", s)
58+
s = _BIDI_CHARS.sub("", s)
59+
s = _MISPLACED_BOM.sub("", s)
60+
return s

agent/tests/test_memory.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
"""Unit tests for pure functions in memory.py."""
22

3+
import hashlib
34
from unittest.mock import MagicMock, patch
45

56
import pytest
67

7-
from memory import _SCHEMA_VERSION, _validate_repo, write_repo_learnings, write_task_episode
8+
from memory import (
9+
_SCHEMA_VERSION,
10+
MEMORY_SOURCE_TYPES,
11+
_validate_repo,
12+
write_repo_learnings,
13+
write_task_episode,
14+
)
15+
from sanitization import sanitize_external_content
816

917

1018
class TestValidateRepo:
@@ -43,6 +51,14 @@ def test_schema_version_is_3(self):
4351
assert _SCHEMA_VERSION == "3"
4452

4553

54+
class TestMemorySourceTypes:
55+
def test_contains_expected_values(self):
56+
assert {"agent_episode", "agent_learning", "orchestrator_fallback"} == MEMORY_SOURCE_TYPES
57+
58+
def test_is_frozen(self):
59+
assert isinstance(MEMORY_SOURCE_TYPES, frozenset)
60+
61+
4662
class TestWriteTaskEpisode:
4763
@patch("memory._get_client")
4864
def test_includes_source_type_in_metadata(self, mock_get_client):
@@ -54,10 +70,11 @@ def test_includes_source_type_in_metadata(self, mock_get_client):
5470
call_kwargs = mock_client.create_event.call_args[1]
5571
metadata = call_kwargs["metadata"]
5672
assert metadata["source_type"] == {"stringValue": "agent_episode"}
73+
assert metadata["source_type"]["stringValue"] in MEMORY_SOURCE_TYPES
5774
assert metadata["schema_version"] == {"stringValue": "3"}
5875

5976
@patch("memory._get_client")
60-
def test_includes_content_sha256_in_metadata(self, mock_get_client):
77+
def test_content_sha256_matches_sanitized_content(self, mock_get_client):
6178
mock_client = MagicMock()
6279
mock_get_client.return_value = mock_client
6380

@@ -66,8 +83,14 @@ def test_includes_content_sha256_in_metadata(self, mock_get_client):
6683
call_kwargs = mock_client.create_event.call_args[1]
6784
metadata = call_kwargs["metadata"]
6885
assert "content_sha256" in metadata
69-
# SHA-256 hex is 64 chars
70-
assert len(metadata["content_sha256"]["stringValue"]) == 64
86+
hash_value = metadata["content_sha256"]["stringValue"]
87+
assert len(hash_value) == 64
88+
89+
# Verify hash matches the sanitized content that was actually stored
90+
content = call_kwargs["payload"][0]["conversational"]["content"]["text"]
91+
sanitized = sanitize_external_content(content)
92+
expected = hashlib.sha256(sanitized.encode("utf-8")).hexdigest()
93+
assert hash_value == expected
7194

7295

7396
class TestWriteRepoLearnings:
@@ -81,10 +104,11 @@ def test_includes_source_type_in_metadata(self, mock_get_client):
81104
call_kwargs = mock_client.create_event.call_args[1]
82105
metadata = call_kwargs["metadata"]
83106
assert metadata["source_type"] == {"stringValue": "agent_learning"}
107+
assert metadata["source_type"]["stringValue"] in MEMORY_SOURCE_TYPES
84108
assert metadata["schema_version"] == {"stringValue": "3"}
85109

86110
@patch("memory._get_client")
87-
def test_includes_content_sha256_in_metadata(self, mock_get_client):
111+
def test_content_sha256_matches_sanitized_content(self, mock_get_client):
88112
mock_client = MagicMock()
89113
mock_get_client.return_value = mock_client
90114

@@ -93,4 +117,10 @@ def test_includes_content_sha256_in_metadata(self, mock_get_client):
93117
call_kwargs = mock_client.create_event.call_args[1]
94118
metadata = call_kwargs["metadata"]
95119
assert "content_sha256" in metadata
96-
assert len(metadata["content_sha256"]["stringValue"]) == 64
120+
hash_value = metadata["content_sha256"]["stringValue"]
121+
assert len(hash_value) == 64
122+
123+
content = call_kwargs["payload"][0]["conversational"]["content"]["text"]
124+
sanitized = sanitize_external_content(content)
125+
expected = hashlib.sha256(sanitized.encode("utf-8")).hexdigest()
126+
assert hash_value == expected

agent/tests/test_models.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,47 @@ def test_extra_top_level_forbidden(self):
201201
}
202202
)
203203

204+
def test_content_trust_none_by_default(self):
205+
hc = HydratedContext(user_prompt="Fix bug")
206+
assert hc.content_trust is None
207+
208+
def test_content_trust_accepted(self):
209+
hc = HydratedContext(
210+
user_prompt="Fix bug",
211+
content_trust={"issue": "untrusted-external", "task_description": "trusted"},
212+
)
213+
assert hc.content_trust == {"issue": "untrusted-external", "task_description": "trusted"}
214+
215+
def test_content_trust_with_memory(self):
216+
hc = HydratedContext(
217+
user_prompt="Fix bug",
218+
content_trust={"memory": "memory", "task_description": "trusted"},
219+
)
220+
assert hc.content_trust is not None
221+
assert hc.content_trust["memory"] == "memory"
222+
223+
def test_content_trust_round_trip(self):
224+
data = {
225+
"version": 1,
226+
"user_prompt": "Do the thing",
227+
"sources": ["issue", "memory"],
228+
"content_trust": {
229+
"issue": "untrusted-external",
230+
"memory": "memory",
231+
},
232+
}
233+
hc = HydratedContext.model_validate(data)
234+
assert hc.content_trust == {"issue": "untrusted-external", "memory": "memory"}
235+
236+
def test_content_trust_invalid_value_rejected(self):
237+
with pytest.raises(ValidationError):
238+
HydratedContext.model_validate(
239+
{
240+
"user_prompt": "Fix bug",
241+
"content_trust": {"issue": "invalid-trust-level"},
242+
}
243+
)
244+
204245

205246
class TestTaskConfig:
206247
def test_required_fields(self):

0 commit comments

Comments
 (0)