@@ -279,6 +279,19 @@ async function followRedirects(
279279 throw new Error ( `Too many redirects (>${ MAX_REDIRECTS } )` ) ;
280280}
281281
282+ // Parse the Content-Signal response header (https://contentsignals.org/) and check whether
283+ // a specific signal is present and set to "no". The header is a comma-separated list of
284+ // key=value pairs, e.g. `ai-train=yes, search=yes, ai-input=no`.
285+ function contentSignalDenies ( response : Response , signal : string ) : boolean {
286+ const header = response . headers . get ( "content-signal" ) ;
287+ if ( ! header ) return false ;
288+ for ( const part of header . split ( "," ) ) {
289+ const [ key , value ] = part . split ( "=" ) . map ( ( s ) => s . trim ( ) . toLowerCase ( ) ) ;
290+ if ( key === signal && value === "no" ) return true ;
291+ }
292+ return false ;
293+ }
294+
282295// Format a `WebFetchResult` as a single string for the agent: a small YAML frontmatter
283296// header followed by `---` then the body. This is friendlier to LLMs than a JSON-wrapped
284297// object, since the body lives inline rather than as an escaped JSON string.
@@ -336,6 +349,20 @@ export async function webFetch(
336349 const finalUrl = response . url ? new URL ( response . url ) : postRedirectUrl ;
337350 const contentType = response . headers . get ( "content-type" ) ?? "" ;
338351
352+ // Respect the Content-Signal header (https://contentsignals.org/). If the site
353+ // explicitly sets `ai-input=no`, we must not feed its content to the AI agent.
354+ if ( contentSignalDenies ( response , "ai-input" ) ) {
355+ try {
356+ await response . body ?. cancel ( ) ;
357+ } catch {
358+ // Ignore.
359+ }
360+ throw new Error (
361+ `The site at ${ finalUrl } sets Content-Signal: ai-input=no, indicating that ` +
362+ `it does not permit its content to be used as AI input.` ,
363+ ) ;
364+ }
365+
339366 const { bytes, truncated } = await readBodyCapped ( response , maxBytes ) ;
340367
341368 let body : string ;
0 commit comments