Skip to content

Commit 305c3dc

Browse files
authored
Merge branch 'main' into feat/browser-screenshot-integration
2 parents 6d65d9e + 396a245 commit 305c3dc

23 files changed

Lines changed: 1407 additions & 95 deletions

agent/src/memory.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,27 @@
77
ERROR level to surface bugs quickly.
88
"""
99

10+
import hashlib
1011
import os
1112
import re
1213
import time
1314

15+
from sanitization import sanitize_external_content
16+
1417
_client = None
1518

1619
# Validates "owner/repo" format — must match the TypeScript-side isValidRepo pattern.
1720
_REPO_PATTERN = re.compile(r"^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$")
1821

19-
# Current event schema version — used to distinguish records written under
20-
# different namespace schemes (v1 = repos/ prefix, v2 = namespace templates).
21-
_SCHEMA_VERSION = "2"
22+
# Current event schema version:
23+
# v1 = repos/ prefix
24+
# v2 = namespace templates (/{actorId}/...)
25+
# v3 = adds source_type provenance + content_sha256 integrity hash
26+
_SCHEMA_VERSION = "3"
27+
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"})
2231

2332

2433
def _get_client():
@@ -50,7 +59,8 @@ def _log_error(func_name: str, err: Exception, memory_id: str, task_id: str) ->
5059
level = "ERROR" if is_programming_error else "WARN"
5160
label = "unexpected error" if is_programming_error else "infra failure"
5261
print(
53-
f"[memory] [{level}] {func_name} {label}: {type(err).__name__}",
62+
f"[memory] [{level}] {func_name} {label}: {type(err).__name__}: {err}"
63+
f" (memory_id={memory_id}, task_id={task_id})",
5464
flush=True,
5565
)
5666

@@ -75,6 +85,9 @@ def write_task_episode(
7585
namespace templates (/{actorId}/episodes/{sessionId}/) place records
7686
into the correct per-repo, per-task namespace.
7787
88+
Metadata includes source_type='agent_episode' for provenance tracking
89+
and content_sha256 for integrity auditing on read (schema v3).
90+
7891
Returns True on success, False on failure (fail-open).
7992
"""
8093
try:
@@ -94,10 +107,16 @@ def write_task_episode(
94107
parts.append(f"Agent notes: {self_feedback}")
95108

96109
episode_text = " ".join(parts)
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()
97114

98115
metadata = {
99116
"task_id": {"stringValue": task_id},
100117
"type": {"stringValue": "task_episode"},
118+
"source_type": {"stringValue": "agent_episode"},
119+
"content_sha256": {"stringValue": content_hash},
101120
"schema_version": {"stringValue": _SCHEMA_VERSION},
102121
}
103122
if pr_url:
@@ -142,12 +161,24 @@ def write_repo_learnings(
142161
namespace templates (/{actorId}/knowledge/) place records into
143162
the correct per-repo namespace.
144163
164+
Metadata includes source_type='agent_learning' for provenance tracking
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.
169+
145170
Returns True on success, False on failure (fail-open).
146171
"""
147172
try:
148173
_validate_repo(repo)
149174
client = _get_client()
150175

176+
learnings_text = f"Repository learnings: {learnings}"
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()
181+
151182
client.create_event(
152183
memoryId=memory_id,
153184
actorId=repo,
@@ -156,14 +187,16 @@ def write_repo_learnings(
156187
payload=[
157188
{
158189
"conversational": {
159-
"content": {"text": f"Repository learnings: {learnings}"},
190+
"content": {"text": learnings_text},
160191
"role": "OTHER",
161192
}
162193
}
163194
],
164195
metadata={
165196
"task_id": {"stringValue": task_id},
166197
"type": {"stringValue": "repo_learnings"},
198+
"source_type": {"stringValue": "agent_learning"},
199+
"content_sha256": {"stringValue": content_hash},
167200
"schema_version": {"stringValue": _SCHEMA_VERSION},
168201
},
169202
)

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: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from config import AGENT_WORKSPACE
1010
from prompts import get_system_prompt
11+
from sanitization import sanitize_external_content as sanitize_memory_content
1112
from shell import log
1213
from system_prompt import SYSTEM_PROMPT
1314

@@ -49,11 +50,11 @@ def build_system_prompt(
4950
if mc.repo_knowledge:
5051
mc_parts.append("**Repository knowledge:**")
5152
for item in mc.repo_knowledge:
52-
mc_parts.append(f"- {item}")
53+
mc_parts.append(f"- {sanitize_memory_content(item)}")
5354
if mc.past_episodes:
5455
mc_parts.append("\n**Past task episodes:**")
5556
for item in mc.past_episodes:
56-
mc_parts.append(f"- {item}")
57+
mc_parts.append(f"- {sanitize_memory_content(item)}")
5758
if mc_parts:
5859
memory_context_text = "\n".join(mc_parts)
5960
system_prompt = system_prompt.replace("{memory_context}", memory_context_text)

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: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
"""Unit tests for pure functions in memory.py."""
22

3+
import hashlib
4+
from unittest.mock import MagicMock, patch
5+
36
import pytest
47

5-
from memory import _validate_repo
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
616

717

818
class TestValidateRepo:
@@ -34,3 +44,83 @@ def test_invalid_spaces(self):
3444
def test_invalid_empty(self):
3545
with pytest.raises(ValueError, match="does not match"):
3646
_validate_repo("")
47+
48+
49+
class TestSchemaVersion:
50+
def test_schema_version_is_3(self):
51+
assert _SCHEMA_VERSION == "3"
52+
53+
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+
62+
class TestWriteTaskEpisode:
63+
@patch("memory._get_client")
64+
def test_includes_source_type_in_metadata(self, mock_get_client):
65+
mock_client = MagicMock()
66+
mock_get_client.return_value = mock_client
67+
68+
write_task_episode("mem-1", "owner/repo", "task-1", "COMPLETED")
69+
70+
call_kwargs = mock_client.create_event.call_args[1]
71+
metadata = call_kwargs["metadata"]
72+
assert metadata["source_type"] == {"stringValue": "agent_episode"}
73+
assert metadata["source_type"]["stringValue"] in MEMORY_SOURCE_TYPES
74+
assert metadata["schema_version"] == {"stringValue": "3"}
75+
76+
@patch("memory._get_client")
77+
def test_content_sha256_matches_sanitized_content(self, mock_get_client):
78+
mock_client = MagicMock()
79+
mock_get_client.return_value = mock_client
80+
81+
write_task_episode("mem-1", "owner/repo", "task-1", "COMPLETED")
82+
83+
call_kwargs = mock_client.create_event.call_args[1]
84+
metadata = call_kwargs["metadata"]
85+
assert "content_sha256" in metadata
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
94+
95+
96+
class TestWriteRepoLearnings:
97+
@patch("memory._get_client")
98+
def test_includes_source_type_in_metadata(self, mock_get_client):
99+
mock_client = MagicMock()
100+
mock_get_client.return_value = mock_client
101+
102+
write_repo_learnings("mem-1", "owner/repo", "task-1", "Use Jest for tests")
103+
104+
call_kwargs = mock_client.create_event.call_args[1]
105+
metadata = call_kwargs["metadata"]
106+
assert metadata["source_type"] == {"stringValue": "agent_learning"}
107+
assert metadata["source_type"]["stringValue"] in MEMORY_SOURCE_TYPES
108+
assert metadata["schema_version"] == {"stringValue": "3"}
109+
110+
@patch("memory._get_client")
111+
def test_content_sha256_matches_sanitized_content(self, mock_get_client):
112+
mock_client = MagicMock()
113+
mock_get_client.return_value = mock_client
114+
115+
write_repo_learnings("mem-1", "owner/repo", "task-1", "Use Jest for tests")
116+
117+
call_kwargs = mock_client.create_event.call_args[1]
118+
metadata = call_kwargs["metadata"]
119+
assert "content_sha256" in metadata
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)