Security model
An LLM edits your live site. Here's why that's safe.
That sentence is the single biggest objection to this product, and it should be. Agent Sean holds write credentials to your website and reads pages authored by people with a financial interest in you ranking worse. Every decision below follows from taking those two facts seriously. None of it is asserted — each claim points at the code that makes it true.
The threat model, stated plainly
A crawling SEO agent has an unusual property: its entire input diet is written by adversaries. The pages it reads to decide what to do are authored by your competitors, and it holds the keys to change your site. Two well-known 2026 incidents are exactly this shape — a tool that bound a public interface with weak or plaintext auth became a one-click compromise. So the design starts from three assumptions:
- Any byte the crawler reads may be a prompt-injection or a poisoned link, and will be treated as hostile.
- The component clever enough to be fooled — the LLM — must never be the component holding a credential or calling a write API.
- Anything the daemon exposes on the network is a defacement surface, so it exposes as close to nothing as a local web app can.
Three planes, and none of them is trusted twice
The work is split into three isolated planes. The property that matters is the intersection: no single plane both reads attacker-controlled bytes and holds a credential. That separation is structural — a consequence of how the code is wired, not a policy someone has to remember to enforce.
Analysis
Crawls and parses third-party pages. Emits closed-schema structs — typed fields, zero free-form strings for later stages to trust.
Planning
Decides what to do and emits typed actions. The LLM lives here and only here — it sees structured findings, never raw pages.
Execution
Applies validated actions: snapshot, write, re-fetch the live page to verify, record a reversible change. Deterministic code — no model in the loop.
Read the columns: the plane that sees hostile input holds no key; the plane that holds a key never sees hostile input; the plane in the middle — the LLM — touches neither raw bytes nor credentials.
Why the model structurally never holds a credential
"The LLM never holds credentials" is easy to claim and hard to trust. Here is why it's true by construction rather than by promise:
It emits data, not commands
The planning plane's only output is a typed Action object drawn from a closed set of about fifty kinds. It cannot express "call this API with this key" — there is no field for it. The kind enum is fixed in packages/actions; the model cannot invent a new one.
A deterministic gate sits between
Every action the model proposes passes through a reference monitor with no network and no LLM before anything is applied. If it survives all fifteen checks, the execution plane — not the model — carries out the write with the credential.
The keys live elsewhere
Site and Google credentials are held by the execution plane and stored through the OS credential store. The prompt sent to the model is built from structured findings; a secret has no path into it. Compromising the model yields a rejected action, not a key.
Autonomy tiers: what each level may do without asking
Every action kind is locked to a tier from T0 to T4, and the tier is not a suggestion the model can override — it's a fixed property of the kind. The higher the blast radius, the more it takes to run it. T3 and T4 cannot be unlocked by any setting.
Snapshot, record a finding, inspect indexation. Writes nothing. A T0 kind that tries to write is vetoed outright.
Reversible field edits that run on their own once the observe window ends: title, meta description, OG tags, alt text, headings, an h1, sitemap regeneration, viewport and lang, broken internal links.
Slightly larger writes, still automatic but rate-capped: JSON-LD, breadcrumbs, internal-link insertion, content refresh, new pages. Content generation is capped at 2 refreshes and 2 new pages per day, per site — a limit with no override.
The dangerous categories — robots.txt, meta robots, redirects, canonicals, hreflang, page deletion, disavow, outreach email, llms.txt. Each needs two distinct cryptographically-signed approvals. There is no auto setting for these.
The things a spam vendor sells and this tool won't do: buying links, PBNs, link exchanges, cloaking, sneaky redirects, hidden text, writing to third-party domains, review gating, scraping the SERP, and generating YMYL or affiliate content. No setting enables them — the validator rejects them with "no setting exists."
The 15-check action validator
Between the model's proposal and any write to your site sits one deterministic function — a reference monitor. It runs no network calls and invokes no LLM. It collects every reason to reject an action (not just the first) so failures are stable and testable, then refuses the action if any check vetoed it. All fifteen, in order:
| # | Check | What it refuses |
|---|---|---|
| 1 | SCHEMA | Anything that isn't a well-formed, in-enum action. |
| 2 | TARGET_BINDING | A target whose URL and page ID don't match a crawled page, or is off-site, or hits a never-touch glob. |
| 3 | URL_ALLOWLIST | A payload URL that was never crawled or allowlisted; off-site URLs need an explicit allowlist. |
| 4 | FIRST_APPEARANCE | Any URL or domain whose first sighting was inside third-party content. One rule that kills canonical injection, attacker redirects, hreflang poisoning and disavow attacks. |
| 5 | DIFF_CAPS | A change larger than the per-kind byte and percentage caps, or a title/meta over its maximum length. |
| 6 | BLAST_RADIUS | A write when the kill switch is on, or once the per-run, hourly, or daily URL caps are hit. |
| 7 | POLICY_TIER | An action whose tier doesn't match its locked tier; every T4; any write during the observe window. |
| 8 | BUDGET | Anything that would push estimated spend past the daily budget. |
| 9 | INVISIBLE_CHARS | Output containing zero-width, bidi-override, or Unicode-tag characters. |
| 10 | ENCODED_PAYLOAD | Base64 or HTML-entity blobs smuggling an instruction into a field. |
| 11 | BANNED_SUBSTRING | Known-bad strings; a title carrying a URL or raw HTML; a meta description carrying HTML. |
| 12 | TWO_KEY | A T3 action without two distinct valid HMAC-signed approvals. |
| 13 | VERTICAL_BLOCK | Content generation on a site categorised YMYL or affiliate. |
| 14 | OBSERVE_PERIOD | Any write while the site is inside its 7-day observe-only window. |
| 15 | RATE_LIMIT | A new page beyond the daily cap, or a content refresh beyond its daily cap. |
The whole monitor lives in packages/actions/src/validator.ts. Each check returns a stable code, which is what lets the red-team suite assert on exact rejection reasons.
The red team: 30 payloads, every one blocked
The suite in redteam.test.ts holds exactly 30 hostile actions — the count is itself asserted, so the number can't quietly drift — and every one is run through the real validator with the assertion that it does not pass. A single false-positive guard runs alongside them: a clean title rewrite must still succeed, so the wall isn't just "reject everything." Adding a payload that gets through is a build failure.
noindex / robots disallow-allX-AI header injectionjavascript: URL<script> in a titleThe daemon's front door is fail-closed
The local daemon runs a web server so the dashboard can talk to it. That server is the network surface, so it is locked down by construction — there is no "insecure mode" that ships on. The full spec is docs/security.md; the middleware is packages/daemon/src/security.ts.
- Loopback only. Binds
127.0.0.1:7777. Asked to bind off-loopback without auth, it throws before the socket opens. - Host allowlist. Only
127.0.0.1,localhost, and[::1]on the right port are accepted;Host: evil.comis a403. A DNS-rebinding test asserts this in CI. - Origin & Sec-Fetch-Site. A present Origin must be on the same allowlist; a
cross-sitefetch is403. - CSRF header on every mutation. Writes require
X-Sean-Csrf: 1; missing it is a403. GET/HEAD are exempt. - Token, constant-time. Mutations must carry a valid token (Bearer header,
X-Sean-Token, or cookie), compared in constant time. It's generated on first boot, at least 32 characters with 8 distinct, and stored in the OS keyring where one exists, in an encrypted file where one does not — headless Linux, containers and CI usually have no Secret Service. Never plaintext on disk either way. - No CORS, ever. No
Access-Control-Allow-*headers are set; any that a plugin adds are stripped on the way out. A PR that adds CORS is treated as a security bug.
The escape hatches: freeze and revert
Two levers give you a hard stop and a clean undo, and both are checked by the code on the paths that matter.
Freeze halts everything
sean freeze writes a HALT file under ~/.sean (or set SEAN_HALT=1). The action validator and the scheduler both check it before every write, so a freeze stops all activity across every site at once — and because it's a file, it survives a restart. sean unfreeze lifts it.
$ sean freeze
Every change is reversible
Before any write, the executor stores a before-snapshot, applies the change, then re-fetches the live page to confirm it landed — rolling back automatically if it didn't. Every applied change is recorded with its snapshot, so sean revert <changeId> restores the prior state from the ledger.
$ sean revert <changeId>
Reporting a vulnerability
Agent Sean holds write credentials to real websites, so security reports are taken as potential live-site incidents. Please don't open a public issue. Instead:
- Email security@agentsean.dev, or
- Open a private GitHub security advisory.
Acknowledgement comes within 3 business days, with a fix or public workaround targeted within 14 days for anything that can expose the daemon or write to a site without an approval a human actually saw. The categories cared about most: anything that lets a crawled page cause a write, anything that bypasses the Host / Origin / CSRF / token checks, anything that logs or returns a decrypted secret, and anything that binds 0.0.0.0 or disables auth by default. Full policy in SECURITY.md.