---
title: "The security header an agent widened to make the feature work"
description: "A strict Content-Security-Policy is usually the thing standing between an agent and the feature it was asked to add. Widening it is the path of least resistance, it is never mentioned in the summary, and nothing about the site looks different afterwards."
url: https://harpywatch.com/blog/the-csp-an-agent-widened
kind: article
author: "The HarpyWatch team"
published: 2026-10-08T09:00:00+00:00
tags: ["AI Agents", "Monitoring", "Reliability"]
publisher: "HarpyWatch"
---

# The security header an agent widened to make the feature work

An AI coding agent is asked to add an analytics snippet. It adds the script tag. The
console fills with `Refused to load the script ... violates the following
Content Security Policy directive`. The error names the exact directive that
refused, and the agent resolves it the shortest way available: it adds
`'unsafe-inline'` to `script-src`.

The feature now works. Nothing in the test suite looks at response headers, so
the suite is green. The pull request says "add analytics".

It does not say "disabled the site's protection against cross-site scripting",
because from where the agent was standing that is not what happened. What
happened is that a configuration was blocking a legitimate resource and the
configuration was corrected.

## Why CSP specifically

Every category of security configuration carries this risk, and a strict CORS
policy gets it too. CSP takes the most damage for a structural reason:
`Strict-Transport-Security` does not stop a feature, and
`X-Content-Type-Options` does not stop a feature, but a tightly scoped CSP stops
features constantly, by design. It is an allowlist covering scripts, styles,
images, fonts, frames and connections, so almost anything new is denied until
someone names it — and the browser prints the exact directive to change.

That makes it the header that generates an error message during development, and
error messages are what an agent optimises against. The loop is: make the thing
work, observe the failure, remove the cause of the failure. A CSP directive is a
cause of failure, and it is the cheapest cause in sight to remove.

There is a correct fix — add the specific origin, or a hash, or a nonce. There
is a fast fix — `'unsafe-inline'`, or `*`, or delete the header while debugging
and forget to restore it. Both produce a working page and only one of them shows
up as different in any test you are likely to have.

## What each loosening actually exposes

Vagueness is not useful here, so:

**`script-src 'unsafe-inline'`** means any injected `<script>` in your HTML
executes. This is the difference between a stored XSS being a rendering bug and
being a full account takeover: the injected script runs with your origin's
privileges, can read the DOM, and can act as the logged-in user. A strict CSP is
one of the few mitigations that works even when the injection itself was not
prevented. Removing it removes the second line of defence, which is the one that
matters precisely because the first one failed.

**`script-src *` or a wildcard CDN** means the set of parties who can run code
on your origin is now the set of parties who can get a file onto that host. This
is the supply-chain case: you are trusting every package that ever ships through
that CDN, forever, on your users' sessions.

**`frame-ancestors` removed** re-enables clickjacking. Your authenticated app
can be loaded invisibly inside somebody else's page with a button positioned
underneath their cursor.

**A cookie without `HttpOnly`** is readable by JavaScript, which turns any XSS
into session theft even without CSP involvement. Without **`Secure`**, the
cookie is sent over plain HTTP, so a network observer on any request that
somehow goes to `http://` collects it. Without **`SameSite`** — and the defaults
here vary by browser and version, which is why relying on them is a bad idea —
the cookie rides along with cross-site requests, which is the whole basis of
CSRF.

**Mixed content** is an HTTPS page loading an HTTP subresource. Browsers block
active mixed content — script, iframes, XHR — and log it to the console, which
means the symptom is a feature that does not work and nothing at all in your
server logs to explain why. Passive mixed content — an image
— may be upgraded or displayed with a downgraded indicator. Either way, the page
made a plaintext request to somewhere, and a network attacker can see it and, for
active content, replace it.

**`Access-Control-Allow-Origin: *`** on an authenticated API is the one people
underestimate most. On its own, with no credentials, it exposes whatever the API
returns to anonymous callers. Combined with
`Access-Control-Allow-Credentials: true` it is a serious hole — browsers
disallow that exact combination, so the common workaround is reflecting the
request's `Origin` header back, which is the same thing with extra steps and is
functionally an invitation for any site your user visits to read their data with
their cookies attached.

## Why review does not catch it

The diff is small. It is often one line inside a long string in an nginx config
or a middleware, and the surrounding line is already three hundred characters of
directives. Adding a token to a CSP is visually indistinguishable from removing
one.

The change also has no behavioural symptom. Nothing renders differently. No test
fails, because tests exercise the application, and the application works better
after this change than before. The end-to-end suite is in fact *more* likely to
pass.

And the reason for the change is genuinely legitimate. Someone did ask for
analytics. The header was in the way. Every step in the chain is defensible; the
outcome is a site with weaker protection than it had, and no record anywhere
that a security decision was made.

The structural difference with an agent is not care, it is context. Whoever
wrote that CSP had a reason for each narrow directive, and that reason was not
written into the file — CSP has no comments in a header. A person who was there
may remember it. A session that opened the file for the first time cannot, so
the directive is just a value that is in the way, and the loop is optimising to
remove the error.

## Watch the response, not the config file

The check that survives all of this is the same one that works for certificates:
look at what a client actually receives, rather than at the file that was supposed to produce
it. Headers get rewritten by proxies, CDNs, and framework middleware, so the
config in the repository is a hypothesis about your production responses, not a
description of them.

HarpyWatch splits this across five checks because they fail independently:

| Check | What it observes |
| --- | --- |
| `security-headers` | The standard set is present and sane — HSTS, `X-Content-Type-Options`, `frame-ancestors` or `X-Frame-Options`, referrer policy |
| `csp-policy` | Content-Security-Policy specifically: which directives exist, and whether the dangerous tokens have appeared |
| `cookie-flags` | `Secure`, `HttpOnly` and `SameSite` on the cookies actually set |
| `mixed-content` | HTTPS pages requesting HTTP subresources |
| `cors-policy` | What your API tells a browser about who may call it |

CSP gets its own check rather than living inside `security-headers` for the
reason this whole piece is about: presence is not the interesting property. A
CSP can be present, syntactically valid, and permissive enough to be decorative.
The question is which tokens are in it, and that question needs asking after
every deploy rather than in an audit once a year.

## The limit

None of this prevents the change. It is external observation, so it tells you
the header on the response is now weaker than it was — after the deploy, not
before it.

If you want the before, run the checks against a staging URL before you point
DNS at it. That is the cheapest moment to find it. The prerequisite is
a staging URL your monitoring can reach from outside, which is a small piece of
work and the only one this asks of you.
