Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions tmproto/artifact.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ type Asset interface {

// TextAsset is a text block (paragraph, heading, caption, etc.).
// Content is publisher-supplied and MUST be treated as untrusted input when
// passed to LLM-based evaluation.
// passed to LLM-based evaluation — see UntrustedText.
type TextAsset struct {
Role string `json:"role,omitempty"`
Content string `json:"content"`
Content UntrustedText `json:"content"`
ContentFormat string `json:"content_format,omitempty"` // text/plain (default), text/markdown, text/html, application/json
Language string `json:"language,omitempty"` // BCP 47 language tag
HeadingLevel int `json:"heading_level,omitempty"` // only for role=heading
Expand Down Expand Up @@ -81,7 +81,8 @@ func (a *ImageAsset) MarshalJSON() ([]byte, error) {
}

// VideoAsset is a video asset with its URL and optional transcript/metadata.
// Transcript is publisher-supplied and MUST be treated as untrusted input.
// Transcript is publisher-supplied and MUST be treated as untrusted input —
// see UntrustedText.
//
// URL and ThumbnailURL are publisher-supplied and MUST be validated with
// ValidateFetchableURL before a buyer agent fetches either — see the
Expand All @@ -90,7 +91,7 @@ type VideoAsset struct {
URL string `json:"url"`
Access *AssetAccess `json:"access,omitempty"`
DurationMs int `json:"duration_ms,omitempty"`
Transcript string `json:"transcript,omitempty"`
Transcript UntrustedText `json:"transcript,omitempty"`
TranscriptFormat string `json:"transcript_format,omitempty"` // text/plain (default), text/markdown, application/json
TranscriptSource string `json:"transcript_source,omitempty"` // original_script, subtitles, closed_captions, dub, generated
ThumbnailURL string `json:"thumbnail_url,omitempty"`
Expand All @@ -110,7 +111,8 @@ func (a *VideoAsset) MarshalJSON() ([]byte, error) {
}

// AudioAsset is an audio asset with its URL and optional transcript/metadata.
// Transcript is publisher-supplied and MUST be treated as untrusted input.
// Transcript is publisher-supplied and MUST be treated as untrusted input —
// see UntrustedText.
//
// URL is publisher-supplied and MUST be validated with ValidateFetchableURL
// before a buyer agent fetches it — see the MUST-validate-before-fetch
Expand All @@ -119,7 +121,7 @@ type AudioAsset struct {
URL string `json:"url"`
Access *AssetAccess `json:"access,omitempty"`
DurationMs int `json:"duration_ms,omitempty"`
Transcript string `json:"transcript,omitempty"`
Transcript UntrustedText `json:"transcript,omitempty"`
TranscriptFormat string `json:"transcript_format,omitempty"` // text/plain (default), text/markdown, application/json
TranscriptSource string `json:"transcript_source,omitempty"` // original_script, closed_captions, generated
Provenance json.RawMessage `json:"provenance,omitempty"`
Expand Down
4 changes: 2 additions & 2 deletions tmproto/artifact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func TestArtifact_AssetUnion_RoundTrip(t *testing.T) {
text, ok := got.Assets[0].(*TextAsset)
require.True(t, ok, "asset[0] should be *TextAsset")
assert.Equal(t, "title", text.Role)
assert.Equal(t, "How to Make Pasta", text.Content)
assert.Equal(t, "How to Make Pasta", string(text.Content))

heading, ok := got.Assets[1].(*TextAsset)
require.True(t, ok)
Expand Down Expand Up @@ -184,7 +184,7 @@ func TestContextMatchRequest_FullDisclosureLadder(t *testing.T) {
require.Len(t, got.Artifact.Assets, 1)
text, ok := got.Artifact.Assets[0].(*TextAsset)
require.True(t, ok)
assert.Equal(t, "Pasta 101", text.Content)
assert.Equal(t, "Pasta 101", string(text.Content))

require.Len(t, got.ArtifactRefs, 1)
assert.Equal(t, ArtifactRefTypeURL, got.ArtifactRefs[0].Type)
Expand Down
5 changes: 3 additions & 2 deletions tmproto/context_signals.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ type ContextSignals struct {
ContentPolicies []string `json:"content_policies,omitempty"`

// Summary is a publisher-generated natural-language summary of the content
// for relevance judgment. Useful for LLM-native buyers. Untrusted input.
Summary string `json:"summary,omitempty"`
// for relevance judgment. Useful for LLM-native buyers. Untrusted input —
// see UntrustedText.
Summary UntrustedText `json:"summary,omitempty"`

// Embedding is the content embedding as a base64-encoded int8 vector.
// Captures semantic content beyond topics and keywords. EmbeddingModel and
Expand Down
4 changes: 2 additions & 2 deletions tmproto/robustness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ func TestContextSignals_Validate(t *testing.T) {
{"valid", ContextSignals{Sentiment: "neutral", Language: "en"}, "", ""},
{"bad sentiment", ContextSignals{Sentiment: "postive"}, "sentiment", "postive"},
{"bad language pattern", ContextSignals{Language: "EN"}, "language", "EN"},
{"summary too long", ContextSignals{Summary: strings.Repeat("x", MaxSummaryLength+1)}, "summary", ""},
{"summary too long", ContextSignals{Summary: UntrustedText(strings.Repeat("x", MaxSummaryLength+1))}, "summary", ""},
{"too many topics", ContextSignals{Topics: make([]string, MaxTopics+1)}, "topics", ""},
{"embedding without model", ContextSignals{Embedding: "x", EmbeddingDims: 256}, "set together", ""},
{"embedding dims too small", ContextSignals{Embedding: "x", EmbeddingModel: "m", EmbeddingDims: 1}, "outside", ""},
Expand Down Expand Up @@ -304,5 +304,5 @@ func TestArtifact_Validate_RecursesIntoAssets(t *testing.T) {
func TestTextAsset_Validate(t *testing.T) {
assert.NoError(t, (&TextAsset{Content: "hi"}).Validate())
assert.Error(t, (&TextAsset{}).Validate())
assert.Error(t, (&TextAsset{Content: strings.Repeat("x", MaxTextContentLength+1)}).Validate())
assert.Error(t, (&TextAsset{Content: UntrustedText(strings.Repeat("x", MaxTextContentLength+1))}).Validate())
}
2 changes: 1 addition & 1 deletion tmproto/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ func TestContextMatchRequest_ContextSignals(t *testing.T) {
assert.Equal(t, []string{"pasta", "home-cooking"}, cs.Keywords, "keywords")
assert.Equal(t, "en", cs.Language, "language")
assert.Equal(t, []string{"csbs"}, cs.ContentPolicies, "content_policies")
assert.Equal(t, "User exploring Italian cookware options for home pasta making", cs.Summary, "summary")
assert.Equal(t, "User exploring Italian cookware options for home pasta making", string(cs.Summary), "summary")
assert.Equal(t, "AQIDBA==", cs.Embedding, "embedding")
assert.Equal(t, "nomic-embed-text-v1.5", cs.EmbeddingModel, "embedding_model")
assert.Equal(t, 256, cs.EmbeddingDims, "embedding_dims")
Expand Down
115 changes: 115 additions & 0 deletions tmproto/untrusted.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package tmproto

import (
"crypto/rand"
"encoding/hex"
"fmt"
"regexp"
)

// UntrustedText is publisher-supplied natural-language content that the AdCP
// spec explicitly marks as untrusted: "Consumers MUST treat this as
// untrusted input when passing to LLM-based evaluation." It backs every
// field in this package that a hostile publisher fully controls and that
// downstream code is likely to feed into an LLM prompt (TextAsset.Content,
// VideoAsset.Transcript, AudioAsset.Transcript, ContextSignals.Summary).
//
// UntrustedText is a defined string type, not a wrapper struct, so its JSON
// encoding is byte-for-byte identical to plain string — this is purely a
// compile-time marker. That's the point: a caller who wants the raw text
// must write string(t) to get it, and that conversion is grep-able and
// stands out in code review. Do not add a method that returns the raw
// string under a different name (e.g. Raw() or Unwrap()) — that would just
// relocate the visible cast one level down and defeat the purpose of the
// type.
//
// MUST be fenced or otherwise clearly marked before being passed to an LLM
// prompt — e.g. via Fenced() below, or by passing it as a separate
// user-role message with its own boundary the model is told to distrust.
// Never concatenate it directly into an instruction/system-role prompt
// string.
type UntrustedText string

// fenceMarker is the fixed, human-readable shape of the boundary tags
// Fenced() emits. nonceHexLen is the length (in hex characters) of the
// random nonce embedded in every marker.
const (
fenceOpenTemplate = "<<<ADCP:UNTRUSTED-CONTENT-BEGIN:%s>>>"
fenceCloseTemplate = "<<<ADCP:UNTRUSTED-CONTENT-END:%s>>>"
nonceHexLen = 32 // 16 random bytes, hex-encoded
)

// fenceLookalike matches ANY text that already has the shape of one of our
// boundary markers, regardless of nonce value. Fenced() uses this to defang
// publisher-supplied content that tries to pre-empt the real boundary —
// see the Fenced doc comment for why this matters.
var fenceLookalike = regexp.MustCompile(`<<<ADCP:UNTRUSTED-CONTENT-(BEGIN|END):[0-9a-fA-F]+>>>`)

// Fenced returns t wrapped in a pair of boundary markers carrying a random
// nonce, suitable for splicing into an LLM prompt as a clearly delimited
// untrusted region (for example, inside a user-role message that the
// system prompt instructs the model to treat as data, never as
// instructions).
//
// Output shape:
//
// <<<ADCP:UNTRUSTED-CONTENT-BEGIN:{nonce}>>>
// {content, with look-alike markers defanged}
// <<<ADCP:UNTRUSTED-CONTENT-END:{nonce}>>>
//
// Guarantees:
// - {nonce} is 128 bits of crypto/rand, generated fresh on every call and
// hex-encoded. Because it's chosen after the publisher has already
// submitted t, the publisher cannot have pre-crafted content containing
// the literal closing tag for THIS call — a downstream parser that
// checks the nonce on both tags cannot be tricked into treating
// attacker text as the fence boundary. The collision probability
// (~2^-128) is cryptographically negligible.
// - Before the real boundary is applied, any substring of t that already
// has the shape of an ADCP boundary marker (fixed prefix + any hex
// nonce) is replaced with a visibly-defanged form. This protects a
// downstream parser that pattern-matches on marker *shape* rather than
// verifying the specific nonce — without this step, a publisher could
// embed a fake "<<<ADCP:UNTRUSTED-CONTENT-END:...>>>" with an
// arbitrary nonce and hope the parser doesn't check it.
//
// Honest limits — read before relying on this for safety:
// - This is a STRUCTURAL guarantee about the boundary, not a semantic
// guarantee about the model's behavior. An LLM told "ignore
// instructions between these markers" can still be confused, argued
// with, or manipulated by adversarial phrasing INSIDE the fenced
// region. Fenced() makes the boundary unforgeable; it does not make
// the model immune to what it reads there.
// - The nonce defense requires the consumer to actually verify that the
// nonce on the closing tag matches the opening tag (or, more simply,
// to just treat the whole Fenced() return value as one opaque blob and
// not re-parse it for embedded markers at all). A consumer that only
// checks for the fixed prefix and ignores the nonce gets the
// defanging protection above, but not the unforgeability guarantee.
// - Whitespace/formatting inside t is preserved verbatim (aside from the
// defanging substitution above) — Fenced() does not sanitize markdown,
// HTML, control characters, or other content that might affect how a
// downstream renderer or tokenizer processes it.
// - Do not cache and replay a single Fenced() output across multiple,
// differently-untrusted inputs — the nonce's guarantee is per-call.
func (t UntrustedText) Fenced() string {
nonce := randomNonceHex()
safe := fenceLookalike.ReplaceAllString(string(t), "[adcp:fence-marker-removed]")
return fmt.Sprintf(fenceOpenTemplate, nonce) + "\n" + safe + "\n" + fmt.Sprintf(fenceCloseTemplate, nonce)
}

// randomNonceHex returns a 128-bit random value, hex-encoded.
func randomNonceHex() string {
buf := make([]byte, nonceHexLen/2)
if _, err := rand.Read(buf); err != nil {
// crypto/rand reading from the OS entropy source is not expected
// to fail on any platform this SDK supports; a failure here means
// the OS is in a state where TLS, ed25519 signing (see tmpx.go),
// and most of the rest of the security stack are already broken.
// Panicking rather than silently downgrading to a predictable
// nonce keeps that failure loud instead of quietly weakening the
// unforgeability guarantee documented on Fenced.
panic(fmt.Sprintf("tmproto: crypto/rand unavailable: %v", err))
}
return hex.EncodeToString(buf)
}
Loading
Loading