Skip to content

Commit 396a245

Browse files
author
bgagent
committed
chore(memory): add metadata for trust
1 parent 9019784 commit 396a245

10 files changed

Lines changed: 263 additions & 7 deletions

File tree

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/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):

cdk/src/handlers/shared/context-hydration.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ export interface GitHubPullRequestContext {
8989
readonly issue_comments: IssueComment[];
9090
}
9191

92+
/**
93+
* Trust classification for content sources in the hydrated context.
94+
* - 'trusted': authenticated user input (task_description — screened by guardrail at
95+
* submission, additionally sanitized during prompt assembly). Lower risk than external
96+
* sources but not exempt from defense-in-depth sanitization.
97+
* - 'untrusted-external': GitHub issues, PR bodies/comments (attacker-controllable,
98+
* sanitized + guardrail screened). Some PR sub-fields are not sanitized:
99+
* diff_hunk and diff_summary (code/patch content in markdown code blocks),
100+
* path (file paths), head_ref and base_ref (branch names).
101+
* - 'memory': memory records (sanitized, integrity-hashed)
102+
*/
103+
export type ContentTrustLevel = 'trusted' | 'untrusted-external' | 'memory';
104+
92105
/**
93106
* The result of the context hydration pipeline.
94107
*/
@@ -104,6 +117,7 @@ export interface HydratedContext {
104117
readonly guardrail_blocked?: string;
105118
readonly resolved_branch_name?: string;
106119
readonly resolved_base_branch?: string;
120+
readonly content_trust?: Readonly<Record<string, ContentTrustLevel>>;
107121
}
108122

109123
// ---------------------------------------------------------------------------
@@ -860,6 +874,44 @@ export function assemblePrIterationPrompt(
860874
return parts.join('\n');
861875
}
862876

877+
// ---------------------------------------------------------------------------
878+
// Content trust classification
879+
// ---------------------------------------------------------------------------
880+
881+
/**
882+
* Build the content_trust record from the sources list.
883+
* Maps each source to its trust classification:
884+
* - 'issue', 'pull_request' → 'untrusted-external'
885+
* - 'memory' → 'memory'
886+
* - 'task_description' → 'trusted'
887+
* Unknown sources default to 'untrusted-external' (fail-safe).
888+
*/
889+
export function buildContentTrust(sources: string[]): Record<string, ContentTrustLevel> {
890+
const trust: Record<string, ContentTrustLevel> = {};
891+
for (const source of sources) {
892+
switch (source) {
893+
case 'issue':
894+
case 'pull_request':
895+
trust[source] = 'untrusted-external';
896+
break;
897+
case 'memory':
898+
trust[source] = 'memory';
899+
break;
900+
case 'task_description':
901+
trust[source] = 'trusted';
902+
break;
903+
default:
904+
logger.warn('Unknown content source — defaulting to untrusted-external', {
905+
source,
906+
metric_type: 'unknown_content_source',
907+
});
908+
trust[source] = 'untrusted-external';
909+
break;
910+
}
911+
}
912+
return trust;
913+
}
914+
863915
// ---------------------------------------------------------------------------
864916
// Main hydration pipeline
865917
// ---------------------------------------------------------------------------
@@ -964,13 +1016,15 @@ export async function hydrateContext(task: TaskRecord, options?: HydrateContextO
9641016
task_id: task.task_id, pr_number: task.pr_number, task_type: task.task_type,
9651017
});
9661018
const fallbackPrompt = assembleUserPrompt(task.task_id, task.repo, undefined, task.task_description);
1019+
const fallbackSources = task.task_description ? ['task_description'] : [];
9671020
return {
9681021
version: 1,
9691022
user_prompt: fallbackPrompt,
970-
sources: task.task_description ? ['task_description'] : [],
1023+
sources: fallbackSources,
9711024
token_estimate: estimateTokens(fallbackPrompt),
9721025
truncated: false,
9731026
fallback_error: `Failed to fetch PR #${task.pr_number} context from GitHub`,
1027+
content_trust: buildContentTrust(fallbackSources),
9741028
};
9751029
}
9761030

@@ -1067,6 +1121,7 @@ export async function hydrateContext(task: TaskRecord, options?: HydrateContextO
10671121
sources,
10681122
token_estimate: estimateTokens(userPrompt),
10691123
truncated,
1124+
content_trust: buildContentTrust(sources),
10701125
...(guardrailBlocked && { guardrail_blocked: guardrailBlocked }),
10711126
};
10721127

@@ -1096,6 +1151,7 @@ export async function hydrateContext(task: TaskRecord, options?: HydrateContextO
10961151
sources,
10971152
token_estimate: tokenEstimate,
10981153
truncated: budgetResult.truncated,
1154+
content_trust: buildContentTrust(sources),
10991155
...(guardrailBlocked && { guardrail_blocked: guardrailBlocked }),
11001156
};
11011157
} catch (err) {
@@ -1120,13 +1176,15 @@ export async function hydrateContext(task: TaskRecord, options?: HydrateContextO
11201176
metric_type: 'hydration_infra_failure',
11211177
});
11221178
const fallbackPrompt = assembleUserPrompt(task.task_id, task.repo, undefined, task.task_description);
1179+
const fallbackSources = task.task_description ? ['task_description'] : [];
11231180
return {
11241181
version: 1,
11251182
user_prompt: fallbackPrompt,
1126-
sources: task.task_description ? ['task_description'] : [],
1183+
sources: fallbackSources,
11271184
token_estimate: estimateTokens(fallbackPrompt),
11281185
truncated: false,
11291186
fallback_error: err instanceof Error ? err.message : String(err),
1187+
content_trust: buildContentTrust(fallbackSources),
11301188
};
11311189
}
11321190
}

cdk/src/handlers/shared/orchestrator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B
268268
pr_number: task.pr_number,
269269
sources: hydratedContext.sources,
270270
token_estimate: hydratedContext.token_estimate,
271+
...(hydratedContext.content_trust && { content_trust: hydratedContext.content_trust }),
271272
});
272273
} catch (eventErr) {
273274
logger.error('Failed to emit guardrail_blocked event', {
@@ -352,6 +353,7 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B
352353
truncated: hydratedContext.truncated,
353354
prompt_version: promptVersion,
354355
has_memory_context: !!hydratedContext.memory_context,
356+
...(hydratedContext.content_trust && { content_trust: hydratedContext.content_trust }),
355357
...(hydratedContext.fallback_error && { fallback_error: hydratedContext.fallback_error }),
356358
});
357359
return payload;

cdk/test/handlers/orchestrate-task.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ describe('hydrateAndTransition', () => {
139139
sources: ['task_description'],
140140
token_estimate: 20,
141141
truncated: false,
142+
content_trust: { task_description: 'trusted' },
142143
};
143144

144145
test('transitions to HYDRATING and returns payload with hydrated_context', async () => {
@@ -198,6 +199,7 @@ describe('hydrateAndTransition', () => {
198199
expect(guardrailEvent.metadata.pr_number).toBe(10);
199200
expect(guardrailEvent.metadata.sources).toEqual(['task_description']);
200201
expect(guardrailEvent.metadata.token_estimate).toBe(20);
202+
expect(guardrailEvent.metadata.content_trust).toEqual({ task_description: 'trusted' });
201203
});
202204

203205
test('still throws guardrail error when emitTaskEvent fails during guardrail_blocked handling', async () => {
@@ -232,6 +234,7 @@ describe('hydrateAndTransition', () => {
232234
expect(metadata.sources).toEqual(['task_description']);
233235
expect(metadata.token_estimate).toBe(20);
234236
expect(metadata.truncated).toBe(false);
237+
expect(metadata.content_trust).toEqual({ task_description: 'trusted' });
235238
});
236239
});
237240

@@ -422,6 +425,7 @@ describe('hydrateAndTransition with blueprint config', () => {
422425
sources: ['task_description'],
423426
token_estimate: 20,
424427
truncated: false,
428+
content_trust: { task_description: 'trusted' },
425429
};
426430

427431
test('includes system_prompt_overrides in payload when blueprint config has them', async () => {
@@ -707,6 +711,7 @@ describe('hydrateAndTransition — memory and prompt version', () => {
707711
sources: ['task_description'],
708712
token_estimate: 20,
709713
truncated: false,
714+
content_trust: { task_description: 'trusted' },
710715
};
711716

712717
test('passes memoryId to hydrateContext', async () => {
@@ -733,6 +738,7 @@ describe('hydrateAndTransition — memory and prompt version', () => {
733738
...mockHydratedContextBase,
734739
memory_context: { repo_knowledge: ['test'], past_episodes: [] },
735740
sources: ['task_description', 'memory'],
741+
content_trust: { task_description: 'trusted', memory: 'memory' },
736742
});
737743
await hydrateAndTransition(baseTask as any);
738744

@@ -743,6 +749,7 @@ describe('hydrateAndTransition — memory and prompt version', () => {
743749
const metadata = putCalls[0][0].input.Item.metadata;
744750
expect(metadata.has_memory_context).toBe(true);
745751
expect(metadata.prompt_version).toBe('abc123def456');
752+
expect(metadata.content_trust).toEqual({ task_description: 'trusted', memory: 'memory' });
746753
});
747754

748755
test('stores prompt_version on task record via DDB UpdateCommand', async () => {

0 commit comments

Comments
 (0)