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:

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.

Plane 01

Analysis

Crawls and parses third-party pages. Emits closed-schema structs — typed fields, zero free-form strings for later stages to trust.

✓ Reads third-party bytes
✗ Holds credentials
Plane 02

Planning

Decides what to do and emits typed actions. The LLM lives here and only here — it sees structured findings, never raw pages.

✗ Reads third-party bytes
✗ Holds credentials
Plane 03

Execution

Applies validated actions: snapshot, write, re-fetch the live page to verify, record a reversible change. Deterministic code — no model in the loop.

✗ Reads third-party bytes
✓ Holds credentials

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.

T0 Observe

Snapshot, record a finding, inspect indexation. Writes nothing. A T0 kind that tries to write is vetoed outright.

T1 Auto

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.

T2 Auto, structural

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.

T3 Two-key human approval

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.

T4 Refused, permanently

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:

#CheckWhat it refuses
1SCHEMAAnything that isn't a well-formed, in-enum action.
2TARGET_BINDINGA target whose URL and page ID don't match a crawled page, or is off-site, or hits a never-touch glob.
3URL_ALLOWLISTA payload URL that was never crawled or allowlisted; off-site URLs need an explicit allowlist.
4FIRST_APPEARANCEAny URL or domain whose first sighting was inside third-party content. One rule that kills canonical injection, attacker redirects, hreflang poisoning and disavow attacks.
5DIFF_CAPSA change larger than the per-kind byte and percentage caps, or a title/meta over its maximum length.
6BLAST_RADIUSA write when the kill switch is on, or once the per-run, hourly, or daily URL caps are hit.
7POLICY_TIERAn action whose tier doesn't match its locked tier; every T4; any write during the observe window.
8BUDGETAnything that would push estimated spend past the daily budget.
9INVISIBLE_CHARSOutput containing zero-width, bidi-override, or Unicode-tag characters.
10ENCODED_PAYLOADBase64 or HTML-entity blobs smuggling an instruction into a field.
11BANNED_SUBSTRINGKnown-bad strings; a title carrying a URL or raw HTML; a meta description carrying HTML.
12TWO_KEYA T3 action without two distinct valid HMAC-signed approvals.
13VERTICAL_BLOCKContent generation on a site categorised YMYL or affiliate.
14OBSERVE_PERIODAny write while the site is inside its 7-day observe-only window.
15RATE_LIMITA 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.

01·25 Off-site canonical injection
02·11 noindex / robots disallow-all
03·04 Prompt injection in a title or meta
05·19 Hidden-text link to a foreign domain
06 Unicode tag-block smuggling
07 Bidi (RLO) override
08 Zero-width character split
09 X-AI header injection
10 Off-site 301 redirect
12 Attacker-requested disavow
13 Write to a third-party domain
14 Link buying / PBN
15 Cloaking on user-agent
16 YMYL content generation
17 Base64-encoded instruction
18 HTML-entity-encoded instruction
20 New-page rate-limit abuse
21 Diff bomb (oversized rewrite)
22 Cross-page target mismatch
23 javascript: URL
24 <script> in a title
26 Injection in alt text
27 JSON-LD review-rating spam
28 Attacker hreflang target
29 Page deletion
30 Write during observe period

The 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.

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:

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.