Disquiet — platform overview

01
thesis

severing the model's head from its hands

Most agentic security tools wrap a model in shell access and trust it to carry out attacks correctly. Models are bad at this. They hallucinate payloads, miscount Content-Length bytes, drop framing, get HPACK encoding wrong, and forget which TLS version they negotiated three turns ago. Disquiet takes the opposite design: the reasoner cannot act directly. Every capability lives as a deterministic primitive the model must reason over, and every byte on the wire was constructed by code that knows the protocol. The interface between them is a structured emit shape — domain-annotated, orthogonal-by-axis — that gives the reasoner the facts it needs without bytes to re-derive. This structural shape is severance.

Severance is the structural shape, not the whole platform. The other two pieces: a primitive substrate built and pruned by the model under adversarial pressure (§3–4), and an externally-anchored audit substrate that makes severance verifiable rather than asserted (§9).

primitives handle the protocol
The model reasons about what to do; primitives handle the doing. Content-Length byte counts, HTTP/2 framing, HPACK, TLS state, single-packet timing — those live in primitives, not in the model. The reasoner never has to recover from a malformed frame or a connection-level mistake.
no shortcut to direct execution
On the platform itself: no shell, no eval, no arbitrary code. The shortcut paths a model would reach for if they were available — typing curl from a chat prompt, chaining requests through a shell loop, writing itself a scratch script — aren't there. Hand-built bytes are available where protocol work needs them, through raw_send, and even there the primitive resolves {CL} and {LEN:...} itself rather than trusting the model to count. The model still drives reasoning; action only flows through the primitives.
primitives, not tools
No wrapped "exploit-server" tool that hides technique behind a one-shot abstraction. Operations compose from raw HTTP, sockets, payload generators, response classifiers. An operator reading the trace can see exactly how an attack was assembled — and intervene at any layer. Higher-level operations exist because they're useful, not because they're prescribed.

Prior art: DeepMind's CaMeL proposes the same reasoner / execution split as a general agent-safety pattern. Simon Willison has written extensively on why prompt-injection resistance is fundamentally an architectural problem — not a prompt-hygiene one. Disquiet is a specialization of that structural insight for offensive security: scope enforcement, declared-effect gates, and orthogonal-axis emit design.

02
structural enforcement

where severance lives in code

The thesis above is a design claim; the three pieces below are what makes it true rather than asserted. Each is a boot-gated or primitive-layer enforcement point the model cannot prompt its way around, because none of them are model-side.

tool registry
one registry, validated at boot
Every operation the model can call lives in a single Python map: 190+ op_name → (module, function) entries. The dispatcher resolves tool calls only through this map. No runtime registration, no plugin loading, no reflection. The model cannot invoke bash, eval, or exec because no such entries exist. A boot-time gate walks every entry, imports each function, and refuses to start the VM if any entry is unresolvable.
scope walk
scope walked recursively, signature-checked at boot
Each tool declares which argument keys carry targets. The dispatcher extracts every string at a target-bearing key, one level into nested dicts, lists, and batch shapes, and checks each one against the engagement's scope before the call dispatches. A second boot gate cross-references the declared keys against each tool function's signature: if a declared key matches no real parameter, or a tool grew a resolver or nameserver argument without registering it for scope-extraction, the VM refuses to start. Scope itself changes only in the dashboard, never through the agent.
envelope
prompt-injection defenses in code, not the prompt
Target-controlled strings go into an <untrusted> envelope with closing tags neutralized so attacker bytes can't escape into instructions, and opening tags neutralized so they can't forge a provenance marker of their own. The wrapping happens at the leaf primitive that read the bytes rather than at an outer edge, so the boundary holds when a value gets carried through summarization or re-framed by a later tool. Gate refusals come back with the MCP error flag set, which nothing in a target response can do, so a refusal quoted inside a response body can't pass for a real one and suppress coverage of an in-scope host.
03
build methodology

adversarial-loop primitive synthesis

The primitive boundaries weren't designed up front. They were the boundaries the reasoner kept trying to cross. During the build, the reasoner drove against live targets; every time it reached for inline Python, raw curl, or an external library, a guardrail caught the moment and the missing capability became a new first-class primitive — authored by the model, in the shape it wanted to find waiting next time. The shape of the surface was discovered, not specified.

This inverts the orthodox approach to building offensive AI. The orthodox frame: TTPs are knowledge, knowledge lives in model weights, and the system improves by curating training data and fine-tuning the model. Disquiet's frame: TTPs are capabilities, capabilities live in primitives, the model is a fixed substrate, and the platform is what compounds. Every guardrail trip is a labeled training example for the platform. The model specifies the platform.

What that gets you: the platform's synthesis loop is model-agnostic — any sufficiently capable LLM under adversarial pressure surfaces the same gaps. How well a given model then drives an engagement is a different question, treated in §6. Dangerous knowledge is locatable and gateable, sitting in primitives that can be access-controlled, audited, and revoked, rather than baked into weights. And platform improvement composes with model improvement: a smarter model produces sharper specs, not obsolete training data.

One question this design invites: if the primitive is model-authored, what makes it more reliable than the model bytes it replaces? Part of the answer is checks that hold across the whole catalog rather than per primitive, and they gate every deploy that ships the catalog. Every function that decodes raw target bytes is bound into a hostile-input property harness or exempted with a written reason, and a new decoder that is neither fails the gate; the property every decoder is held to is that truncating a valid field can never strengthen a verdict. A request that failed in transport has the same shape as a response with an empty body, so a sweep fails the gate wherever a tool compares against a reference without first checking that the reference was a measurement at all. Detection fixes ship with a suppression fixture: the real tool body, driven over a scripted transport against a target that is genuinely vulnerable and whose control fails for its own reasons, must not come back clean. None of that proves a primitive right; it keeps known classes of wrong from shipping silently. The other half of the answer is where the bug lives. A primitive's byte counter, once wrong, is wrong the same way every call — one file, one fix, exercisable in isolation. A model's byte counter is freshly wrong per turn. Whatever the primitive actually sent on the wire is preserved in the anchored audit chain, with credential values withheld, so a wrong verdict can be inspected against the raw bytes after the fact. Model fallibility is unbounded and per-turn; primitive fallibility is deterministic, locatable, and auditable.

The adversarial loop keeps pressuring every primitive after it lands. The same gap signal that produced it fires again on the next run if the primitive still gets the case wrong, and the generalization pass in §4 folds, splits, or replaces it. There's no certification step — the loop just keeps running.

Prior art: Voyager (Wang et al., 2023) demonstrated the same closed-loop intuition in a fully autonomous game context — an LLM growing its own skill library by writing new code when existing skills fell short. Disquiet applies the same pattern in an adversarial environment with one variation: a human operator gates whether a moment of capability-shortfall produces a throwaway shell command or a first-class primitive. The model still authors the code; the operator decides what deserves to become permanent. Implementation stays with the model; architectural taste stays human.

04
build methodology, cont.

generalization passes — pruning the surface

Adversarial-loop synthesis is the growth half; generalization is the pruning half. Once the catalog has enough primitives to see sibling-shape patterns, the platform runs a structured audit pass: every primitive gets tagged on its taxonomy (protocol / technique / flaw-specific), kwarg surfaces get scanned for arg-explosion smells, sibling clusters get scanned for collapse opportunities, and the recurring kwarg-clusters across primitives get formalized as typed axes. The audit produces a punch list; the implementation pass applies it.

Each pass folds sibling primitives onto axes. Five subdomain-source primitives became one subdomain_discover(source=). Six smuggle-loop variants became one smuggle_loop(goal=, http_version=). Three cross-session-diff shapes became one cross_session_scan(comparison_mode=). The SNMP v3 family folded into a version=-aware snmp_walk and snmp_credential_enum. One pass in May 2026 took the catalog from 183 primitives to 169 while every collapsed capability stayed reachable through a typed kwarg.

The operating principle that emerges: the catalog grows slowly in primitives, fast in cross-cutting axes. A new capability that fits as a new value on an existing axis (source='new_provider', goal='new_loop_shape', version='vN') doesn't become a new primitive — it extends the typed surface its siblings already share. New primitives only land when the capability genuinely doesn't fit. Synthesis discovers what to build. Generalization discovers what to collapse. Both runs share the same source-of-truth: real adversarial pressure against live targets, captured in an audit graph that survives reorganization.

05
worked example

what a primitive emits

Where the data has real multi-axis structure, a primitive surfaces it as separate axes instead of flattening it to one verdict; where it doesn't, the emit stays simple. cors_probe(url) below sends six origin variants and a preflight to one target, and keeps three questions apart: whether an attacker origin can read a response, what that read is worth given who can host the origin, and whether the browser will send the request at all.

The run: per-probe rows, from the emit's summary

  [ok              ] evil_origin       — ACAO: '' | ACAC: absent | browser: no cross-origin read | reach: any_page | HTTP 200
  [ok              ] null_origin       — ACAO: '' | ACAC: absent | browser: no cross-origin read | reach: any_page | HTTP 200
  [HIGH     score=7] subdomain_prefix  — ACAO: 'https://evil.tar.com' | ACAC: true | browser: credentialed read | reach: subdomain_foothold
  [CRITICAL score=9] dotless_prefix    — ACAO: 'https://eviltar.com' | ACAC: true | browser: credentialed read | reach: any_page
  [ok              ] subdomain_suffix  — ACAO: '' | ACAC: absent | browser: no cross-origin read | reach: any_page | HTTP 200
  [HIGH     score=6] http_downgrade    — ACAO: 'http://tar.com' | ACAC: true | browser: credentialed read | reach: network_position

Pre-flight (OPTIONS, Origin: https://evil.com, asks GET + Authorization): HTTP 403 — no CORS headers, not approved

Pre-flight (OPTIONS, Origin: https://evil.com, asks PUT + Content-Type): HTTP 403 — no CORS headers, not approved

Pre-flight (OPTIONS, Origin: https://eviltar.com, asks GET + Authorization): HTTP 204
  ACAO           : 'https://eviltar.com' | ACAC: true
  Allow-Headers  : Authorization
  Allow-Methods  : GET,DELETE
  browser        : credentialed send
  unlocks        : DELETE
  [HIGH     score=6] preflight approves https://eviltar.com with credentials and unlocks DELETE
                     → a page on https://eviltar.com can send requests using DELETE in
                       credentialed mode, which a simple request cannot
                     → cookie condition: cross-site to the target: a session cookie rides only
                       if it is SameSite=None and not Partitioned, …
                     …

Verdict: cross-origin exposure — max score 9, 4 exposure findings
Strength: INFERRED — the headers were measured; that an ambient credential, usually a session cookie, rides the request was not (each finding names its cookie condition)

Why orthogonal axes matter. In this run dotless_prefix and subdomain_prefix are accepted and subdomain_suffix is not, so the origin check is a suffix match with no dot boundary, something like endswith("tar.com"). http_downgrade passing too says the scheme isn't part of it. The three accepted origins score 9, 7 and 6 because each needs a different attacker to put a page on it: anyone can register eviltar.com, evil.tar.com takes script on a subdomain of the host, and http://tar.com takes a network position. The send axis answers a different question. The preflight from evil.com is turned away and the one from eviltar.com is approved, unlocking DELETE, which a page there can send whether or not the response can ever be read back. A "CORS: vulnerable" verdict would destroy all of it; the reasoner needs the axes separated to pick the exact bypass that fits. Each exposure is also marked INFERRED: the headers were measured, while the ambient credential that makes a credentialed read worth anything, usually a session cookie, was not.

One entry from the emit's findings array

{
  "label": "dotless_prefix",
  "origin": "https://eviltar.com",
  "origin_reachability": "any_page",
  "acao": "https://eviltar.com",
  "acac": "true",
  "with_credentials": true,
  "browser_outcome": "credentialed",
  "verdict": "CRITICAL",
  "score": 9,
  "verdict_strength": "INFERRED",
  "issue": "Origin reflected with Allow-Credentials: true — a page on https://eviltar.com can read …",
  "rule": "Fetch CORS check: Access-Control-Allow-Origin equal to the request's Origin plus …",
  "reach_condition": "any page can claim this origin: a registered domain, …",
  "cookie_condition": "cross-site to the target: a session cookie rides only if it is SameSite=None and not Partitioned, …",
  "falsifier": "the session cookie is SameSite=Lax, SameSite=Strict, unset (Lax by default in Chromium) or Partitioned, …"
}
how cors_probe scores itprobe variants · read axis · send axis

Probe variants (one GET each; attacker origins take the target's scheme)

Label Origin sent What it tests Who can host it
evil_originhttps://evil.comArbitrary external originany page
null_originnullSandboxed-iframe originany page
subdomain_prefixhttps://evil.{host}Subdomains are trusted, as a correct *.host allowlist also trusts themscript on a subdomain
dotless_prefixhttps://evil{domain}Suffix check with no dot boundary, such as endswith()any page
subdomain_suffixhttps://{host}.evil.comPrefix check: startswith(), an unanchored pattern or a substring matchany page
http_downgradehttp://{host}Scheme not enforced (https targets only)network position
preflightOPTIONS from https://evil.com and from the highest-scoring origin the read axis acceptedWhat a preflight unlocks for an attacker origin, and whether OPTIONS and GET disagreeper origin

Read axis — origin acceptance × credential willingness, priced by who can host the origin

ACAO ACAC Host Verdict Score Why
reflected (== origin) true any page CRITICAL 9 Any page can claim the origin, through a registered domain or a sandboxed iframe for null, and read responses to credentialed requests. Cross-site, so a session cookie is on them only if it is SameSite=None and not partitioned, in a browser that sends third-party cookies.
reflected (== origin) true subdomain HIGH 7 Needs script on a subdomain of the host. Same-site, so cookies ride whatever their SameSite value.
reflected (== origin) true network HIGH 6 Needs a network position to answer for the plaintext origin, in a browser that neither holds the host's HSTS policy nor upgrades http navigations. When the probed URL's response carries HSTS it scores 4, since a victim with a session has visited over https and will usually hold the policy.
reflected (== origin) absent / false any LOW 2 Readable without credentials, the same exposure as *, which matters only where the victim's browser reaches a host the attacker can't. Recorded because the server accepts this origin.
* (wildcard) true — LOW 2 A credentialed request needs an exact origin match, so browsers reject this pair on one. The credentials directive is inert; behavior is identical to the row below. Configuration signal, not exposure.
* (wildcard) absent / false — ok — Spec-compliant; public resource.
unrelated (not echoed) any — ok — Origin not accepted; probe variant didn't break through.

Send axis — the preflight channel, scored on its own

Preflight response Verdict Score Why
approves the origin, ACAC: true, unlocks a non-safelisted method or Content-Type HIGH 6 A page on that origin can send requests a simple request couldn't, such as a DELETE or an application/json body, in credentialed mode, under the same cookie condition as the read. From a subdomain or the plaintext origin it scores lower, by the same steps as the read axis, HSTS included.
approves the origin, ACAC: true, unlocks only a custom header MEDIUM 4 Matters only where the server requires that header, such as a custom-header CSRF defense.
approves the origin, ACAC: true, unlocks nothing beyond Authorization LOW 2 Authorization needs a preflight, but the page can only put a token it already holds in it.
approves the origin, no credentials LOW 2 Same delivery as a * preflight, which the attacker's own client could send. Recorded because the preflight accepts this origin.
* (wildcard) ok — Public preflight; same class as a bare * on the response itself.
ACAO absent / mismatched, or non-2xx ok — The browser discards the preflight and never sends the request. Whatever Allow-Headers lists is unreachable.

What the preflight is not scored on. Authorization in Allow-Headers is recorded as a property of an approved channel, never as a finding by itself. A cross-origin page can only put a token it already holds in it, which it could send from its own client. The spec excludes it from the Allow-Headers wildcard, so every SPA that sends bearer tokens to an API on a separate origin has to name it explicitly, and servers that echo Access-Control-Request-Headers straight back return it only because the probe asked for it. Scoring it on its own would flag that correct configuration.

Which preflights go out. One from evil.com, and one from the highest-scoring origin the read axis accepted, because a server that applies one broken allowlist to both GET and OPTIONS turns evil.com away on both. Each asks for GET with Authorization first. A server that echoes the requested method answers that with GET alone, so unless the first unlocks something with credentials, a second asks for PUT and Content-Type. Headers are read as a browser reads them: names case-insensitive, repeated lines joined, so a doubled Access-Control-Allow-Origin fails the check here as it does there.

What a verdict is worth. The axes say what was measured; a separate field says how hard the claim binds. CONFIRMED_EFFECT is the consequence itself observed — an execution sentinel that fired, a credential the real issuer accepted. OBSERVED was measured on the wire and claims nothing past the transaction. INFERRED is a rule applied to an observation, with the rule named so the operator can disagree with it. INCONCLUSIVE is the probe running and failing to separate the hypotheses, which is a result: without a first-class inconclusive state every oracle has to pick a side, and an outcome nobody could observe gets written down as safety. Severity doesn't stand in for any of this — a CRITICAL that was inferred and a CRITICAL that was reproduced cost very different amounts when wrong — and only CONFIRMED_EFFECT can stand behind a finding recorded as verified.

Who stated the strength. A finding cites the executions its evidence came from, and those executions emitted strengths of their own. Each time a finding is written or its evidence changes, the platform compares the strength the finding claims with the strongest one its cited executions stated, and records whether the claim is backed by them or only asserted by whoever wrote the finding. A cited output that states no strength, or can't be read, is recorded as that and not as a disagreement. Nothing is refused on this basis. The label is anchored with the finding and printed beside the verdict in every report format, and disquiet_attest re-derives it offline from the cited outputs, so a label edited after the fact fails verification.

Bounding the claim. A finding can carry its falsifier: the target configuration under which it would be wrong, such as an MTA that accepts every recipient and rejects at DATA, under a relay finding raised at RCPT. A specific checkable shape, not a disclaimer — a claim whose falsifier can't be stated was never bounded. The same discipline runs backwards into the probe, where several primitives spend requests establishing what a negative looks like on this target before scoring anything: a corrupted-signature token every conforming endpoint must refuse, a benign file upload that an endpoint refusing everything will refuse too. What survives the control is the finding.

06
operator surface

where the reasoner sits, and what it sees

Today. The reasoner runs in the operator's MCP host. Anthropic's Claude Code and OpenAI's Codex are the proven paths today. The tool surface is exposed over MCP, so other clients (Cursor, etc.) and other models (incl. self-hosted) connect through the same interface — whether a given pairing sustains adversarial-loop reasoning is the operator's call to make against their own engagement. Keys never pool. Works — but the stack is layered (terminal → MCP client → transport → control plane → VM) and each layer charges friction: alt-tab fatigue, tool results as flattened text, round-trip cost on every call.

Near-term: collapse the workspace. A native Rust desktop client, in development, puts the dashboard and the operator's own Claude Code or Codex in one window, with the CLI running in an embedded terminal. It doesn't touch model auth at all — the operator's client runs with whatever credentials it's already configured with locally, the same as in any terminal. What it buys is inhabitability: no alt-tab between the client, the dashboard, and the proxy view. It doesn't change what the platform can do.

Longer-term: collapse the stack. Move the agent loop inside the user's VM, where the tool registry and proxy already live — no MCP wire transit between reasoner and primitives, no laptop as reasoner host, the reasoner one hop from both the tools and the proxy-observed traffic. Bring-your-own model and keys still route directly to the provider, never seen by the control plane. Severance is unchanged: same structured-emit surface, same dispatcher gates. Both shapes coexist with what runs today. This one is blocked upstream for now: no major provider currently lets a third-party application authenticate against a user's subscription via OAuth.

Operator points the browser at the runner's local listener; traffic tunnels through the runner WSS into the per-VM proxy. Audit events fire when the platform acts on the traffic, not on every passthrough: capturing session state, attaching a captured session to a primitive, resending an edited request. A proxy left on by accident doesn't quietly accumulate the operator's personal browsing in the engagement's audit log.

What the reasoner sees, in either shape: the real operator-target conversation, pulled through dedicated MCP tools with credential-bearing header values redacted at the tool boundary. Names pass, credentials mask. Not "what would happen if I sent X," but "here is what is happening." Pass-through observer, not blocker: a modern web page depends on dozens of external origins (CDNs, fonts, OAuth, analytics); none should be blocked. Scope enforcement lives at the primitive layer instead — the dispatcher gates the destination before bytes leave.

07
live traffic

live traffic as substrate

The proxy isn't just a wire to watch. It's the substrate the rest of the platform builds on. Each observation carries a redaction discipline before it leaves the buffer, a live broadcast path the dashboard subscribes to, and a typed surface the reasoner can query without ever seeing raw secrets.

Authenticate two or more accounts through the proxy and let the reasoner drive IDOR, horizontal-privilege, and authorization-boundary testing across every context. Switch identities per primitive call via as_session=<id> — no logout, no credential swap by hand.

TOTP seeds the operator stores once stay server-side; the reasoner generates fresh codes through a dedicated tool without ever holding the secret. The status-quo workflow (authenticate in browser, paste cookies + bearer tokens + MFA codes into sqlmap, ffuf, custom scripts, every tool that wants post-auth state) collapses into one auth flow.

TLS-MITM
per-host leaf minting, sealed CA private key
A single entrypoint handles all three request shapes: absolute-form HTTP, CONNECT then origin-form HTTPS, and CONNECT then h2 over ALPN. Per-host leaf certs mint on SNI callback and cache by hostname. The per-VM CA private key seals through the same encrypted-secret machinery as session state; if the data key is missing at boot, CA generation refuses to write a plaintext key to disk.
ring buffer
in-memory observation buffer with live broadcast
Observations land in a byte-bounded in-process deque (100 MB default, drop-oldest eviction). Writers are blocking proxy threads; readers are asyncio dashboard subscribers. SSE clients receive a monotonic event_id for cursor-based replay through Last-Event-ID; when eviction has rolled past a requested cursor, the stream surfaces a cursor_lost event and the dashboard rehydrates from REST.
session-state
auth surface parsed on every observation
Each observation runs through a session-state extractor: bare Cookie pairs, per-line Set-Cookie parse with attribute flags, Authorization / Proxy-Authorization / x-api-key / x-csrf-token, and regex-matched x-*-token / x-*-key / x-*-auth. When a header value parses as a 3-segment urlsafe-base64 JWT, the extractor decodes header and payload (unverified) and stores exp_unix and alg. Storage is per-(engagement, target_origin), one slot per captured identity; merges are authoritative with Set-Cookie winning over request Cookie.
choke point
captured session applies where requests leave
The captured cookie jar and auth headers attach at the shared HTTP egress boundary, keyed by target origin, rather than inside each primitive. Every HTTP-aware operation carries them without declaring a headers parameter and without appearing on a list of session-aware tools, so a new primitive is session-aware the day it lands. Operator-supplied values win over captured ones (explicit beats inferred). JWTs within 60 seconds of expiry are pre-rejected and the row marked stale; re-authenticating through the proxy clears the stale flag in one shot. Raw-byte primitives like request_smuggle assemble their own wire and never reach that boundary, so nothing auto-augments a request whose exact bytes are the point.
client identity
one client to the target, not one per primitive
Every non-browser primitive egresses through a single client identity applied at that same boundary, so two primitives asking the same question of the same URL are the same client, and one can corroborate the other's negative. The browser primitives take a browser_identity parameter whose default is the real Chromium build's own identity with the automation tells removed. That is not a disguise: a page reads the WebGL renderer, the font list and the platform client hint as well as the user-agent string, so an override that contradicted the browser would be a worse tell than the one it hid. Every request also carries a per-engagement header identifying the program.
multi-context
N parallel auth contexts driven by as_session=
A single primitive call can target a specific captured session by ID: http_fuzz(..., as_session="acct_admin") runs the probe with admin credentials, then as_session="acct_low_priv" runs it with a low-privilege account. The reasoner walks privilege boundaries (IDOR, mass-assignment, role-bypass) without re-authenticating, without logging out, and without ever seeing the underlying cookies. Operator authenticates each account once through the proxy; every subsequent primitive call selects which context drives it.
severance
raw values never serialize back to the reasoner
The session-state module exposes two shapes. A describe() view returns cookie names with Secure/HttpOnly/SameSite flags, header names, has_jwt, and jwt_exp_unix; raw values are never included. An internal injection accessor consumes the raw values server-side and never serializes through any reasoner-facing surface. The same redaction discipline holds in proxy_observation_detail: header names pass, credential-bearing values mask to ***. The reasoner can reason about which auth mechanism is in play without ever holding the secret.
resend
edit a captured request and send it again
Any observation opens as editable raw bytes: request line, headers, body. The edited request is checked against the active engagement's scope and refused if out of scope, then goes out through the proxy's own egress, and the response returns as a new observation. The dial target stays the captured host and port, so an edited Host header rides the request without redirecting where it lands. Content-Length recomputes from what actually goes out. A capture truncated at record time, or a request carrying duplicate header names, is refused before send with the reason shown rather than going out quietly wrong. An h2 capture renders as a request, not a pseudo-header table, so it edits on the same surface an h1 capture does. A one-character change to a request already on screen is too tight a loop to route through the reasoner.
bot challenge
anti-automation labels on every response
A case-insensitive scan over the first 16 KB of each response recognizes the major anti-automation providers (Cloudflare and Turnstile, hCaptcha, reCAPTCHA, Akamai, PerimeterX/HUMAN, DataDome, Fastly, F5 ASM), and a block that returns no body, like Fastly's bodyless 406, is recognized from its status and headers. The proxy path and the primitive path read one shared signature table. A captcha widget on a real page's form is recorded as a note about that page, and labels the response a challenge only when the response is the challenge page itself. Unlabeled, a challenge becomes a false negative: the challenge page is a 200 with HTML in it, so a route_fuzz, param_fuzz, force_browse or crawl run challenged mid-sweep gets a uniform baseline that looks exactly like a genuine negative, and the surface is written off. Labeled, a 200 from a CDN reads as an anti-automation barrier rather than target content, in every observation summary and every primitive's response envelope. The platform labels the challenge and stops there, with no solver and no bypass. Retrying, solving it by hand, or skipping the surface is the operator's call.
08
architecture

system overview

The control plane (disquiet_ctrl) handles auth, provisioning, and proxying. The data plane is a per-user microVM running the MCP server, tool runtime, dashboard, and findings store on an encrypted volume. Scan traffic egresses from the VM or from a user-deployable runner, never from the control plane; a runner's tunnel reaches its VM through the control plane's WebSocket relay.

Disquiet platform architecture data-flow diagram MCP clients and browser connect via HTTPS to the disquiet_ctrl management plane, which reverse-proxies into per-user microVMs running the MCP server, REST API, Next.js dashboard, findings store, needle primitive dispatcher, TLS-MITM proxy, and WebSocket runner dispatcher. Primitive and proxied traffic egresses from the VM to engagement targets. The runner binary on the operator's machine connects back over WebSocket through ctrl, serves as the VM's L3 egress exit, and hosts opt-in signed capability children for name-resolution poisoning and NTLM relay. Audit chain heads anchor every ten minutes from ctrl to four independent trust substrates: Bitcoin via OpenTimestamps, FreeTSA and DigiCert via RFC 3161, and Sigstore Rekor transparency log. A standalone multi-protocol OOB callback service at oobd.io receives blind injection callbacks over DNS, HTTP/S, LDAP, SMTP, FTP, and SMB. TRUST SUBSTRATES · anchored every 10 min Bitcoin OpenTimestamps FreeTSA RFC 3161 DigiCert RFC 3161 Sigstore Rekor transparency log audit anchors EXTERNAL INTERFACE MCP Clients Operator's reasoner + MCP host EXTERNAL INTERFACE Browser dashboard UI · disquiet.ai MCP (HTTPS) HTTPS MANAGEMENT PLANE · always-on · disquiet.ai disquiet_ctrl auth · user provisioning · JWT · reverse proxy · VM lifecycle MCP proxy rev. proxy PER-USER MICROVM · disquiet_scale MCP Server FastMCP REST API Next.js dashboard Findings store SQLite · encrypted volume needle primitive execution · scope-gated 190+ implementations Proxy TLS-MITM · h2 ALPN Runner WS WebSocket dispatcher OOB callbacks STANDALONE SERVICE disquiet_oob *.oobd.io · DNS rebinding DNS · HTTP/S · LDAP · SMTP · FTP · SMB primitives HTTP · TCP · DNS proxied HTTP · HTTPS WebSocket via ctrl control · IP packets · proxy TARGET Engagement Targets USER MACHINE · disquiet_runner signed capability children · opt-in L3 egress userspace netstack Poisoning + capture LLMNR · NBT-NS · mDNS NTLM relay LDAP · MS-SQL · HTTP HTTP · TCP · DNS
disquiet_ctrl
Control plane. Python / Starlette.
disquiet_scale
Per-user microVM. nginx + Python + Next.js. Volume encrypted at rest; sensitive data sealed with a per-VM data key.
disquiet_oob
Blind-callback substrate on a dedicated IP. Receives XSS / SSRF / XXE / command-injection callbacks.
disquiet_runner
Optional Go binary, downloaded as a single file with nothing beside it on disk. Deploys to a laptop, jump host, or cloud VM and connects back over WebSocket with the same tool surface. Tool egress rides an L3 NAT tunnel over that connection, so flows exit from the runner's network with the connection preserved. Connection-bound protocols like NTLM and long-lived TLS work through it, not only stateless HTTP. Past egress, the runner can host signed capability children that work the local segment: observation and opt-in poisoning of mDNS, LLMNR, and NBT-NS name resolution, NetNTLMv2 capture from coerced authentication, and NTLM relay. Captured hashes seal on the VM's encrypted volume through the same machinery as session state, and reads are bound to the engagement that captured them. Revoking the capability tears down its listener and any live tunnel with it.

Single-cloud dependency today. Daily backups of the control-plane database, anchor record included, land on a second independent provider, so anchored evidence survives a vendor outage even when service does not. Per-VM volumes are snapshotted on the primary cloud only. Cold-standby failover across providers is the planned mitigation when operator demand justifies the operational cost.

09
trust substrate

externally anchored audit

Every privileged action on the operator's VM and on Disquiet's own control plane lands in an append-only hash-chained log. Every ten minutes the chain heads merge into a single cross-VM Merkle tree and the root publishes to four independent substrates, three separate failure modes: Bitcoin (via OpenTimestamps), FreeTSA and DigiCert (RFC 3161 named-authority timestamps), and Sigstore Rekor (Linux Foundation transparency log). A standalone verifier walks all of it against pinned roots, with zero network calls to Disquiet.

anchor
2026-11-08 00:40z
btc
✓ confirmed through 11-07 23:10z
freetsa
✓ rfc 3161 timestamp
digicert
✓ rfc 3161 timestamp
rekor
✓ transparency log entry
four substrates
three failure modes, constant load
Bitcoin (OpenTimestamps via three independent calendars), FreeTSA + DigiCert (distinct RFC 3161 named authorities), and Sigstore Rekor submit in parallel every cycle. No single substrate, no single jurisdiction, and no single failure mode can rewrite history alone. Substrate-side load is constant in fleet size: the same few submissions per cycle, regardless of how many user VMs participate. Per-substrate failures soft-fail the cycle; the high-severity alert fires when two consecutive cycles end with no confirmed attestation from any substrate.
per-engagement
inclusion proofs without cross-engagement leakage
Each anchor carries a second Merkle tree built over per-event hashes, with that root bound into the cross-VM leaf. Engagement-filtered exports ship only that engagement's events plus per-event inclusion proofs against the anchor's events_merkle_root, which itself verifies against the publicly anchored cross-VM root. Operators hand a client their engagement's evidence without leaking the existence, scope, or activity of any other engagement on the same VM. Distinct domain-prefixes on leaves and nodes block second-preimage swaps across the two trees.
hash agility
every record commits under two hash functions
Chain rows and Merkle nodes compute under SHA-256 and SHA-3 in parallel, and both legs publish to the substrates. A break in either function leaves the other standing over the same records, which is what evidence expected to outlive any one algorithm needs. A Bitcoin timestamp starts as a pending calendar receipt and is later upgraded to a block confirmation; both legs get upgraded, not only the SHA-256 one, and the verifier refuses an anchor from after the migration that carries only one.
disquiet_attest
vendor-independent verifier
Standalone Go binary (linux + macOS today). Walks the hash chain, replays per-event Merkle proofs, validates each anchor against Bitcoin via independent block explorers that must agree, against pinned RFC 3161 root chains for FreeTSA and DigiCert, and against Sigstore Rekor's pinned transparency-log key. Pinned-root-only, so leaf rotation isn't a coordination event between us and the customer. Embedded SPKI revocation list as last line of defense against a leaked producer key. For findings, it re-derives two things from the cited outputs instead of taking the producer's word: whether a finding's verdict strength was backed by its executions, and the verdict of a finding retest. An anchor in an older schema, dated after its replacement shipped, is rejected outright: no genuine transition produces one, and accepting it would let a producer switch off checks the newer schema added. A verifier binary more than 18 months old says so and won't return a clean pass, since it may be missing revocations for producer keys leaked after it was built.
cross-export
producer cannot lie to one participant
When multiple exports get supplied (control plane plus VM, or two VMs covering an engagement that crosses both), the verifier indexes every (anchor_at, merkle_root, participant) tuple and flags any timestamp two participants signed with disagreeing roots. Same cycle, two participants: the root must match because they were leaves of the same tree. This is the property that makes Disquiet's control plane externally accountable to its operators, not just operators accountable to their clients.
ctrl + oob
the platform holds itself to the same standard
Disquiet's control plane participates in every anchor cycle as a sentinel leaf alongside per-VM heads. Auth events, admin actions, provisioning, and offsite-backup uploads anchor to the same Bitcoin/Rekor/TSA roots. The OOB callback service's hits-chain participates the same way, so blind-vulnerability evidence is anchored too. The same verifier walks all three shapes.
self-assessment
platform's own offensive substrate runs against the platform
The same primitives operators run against their targets — and the same audit, anchoring, and verifier — are also pointed at Disquiet itself. A dedicated operator-role account performs the work in a normal user VM, files findings through the standard insight ingest, and emits primitive.invoked rows into a per-VM hash chain that participates in the same cross-VM Merkle tree as every customer engagement. The verifier doesn't special-case self-assessment evidence — same chain shape, same anchor roots, same verifier walk. The substrate captures the same artifact whether we point it at our own platform or an operator points it at a target.
supply chain
what shipped anchors alongside what ran
Each deploy bakes a CycloneDX SBOM into the image it describes and anchors that document's hash into the same chain as runtime events, bound to the image digest actually released. An SBOM that doesn't describe the image it claims to blocks the provenance record instead of quietly backing it. A recurring scan matches every package across the images Disquiet runs, plus the runner binary operators download, against public advisory data, and raises what's newly applicable through the normal alert path. Anything the scan couldn't read is reported as unread rather than counted clean, because a coverage gap that reads as a clean result is the failure mode worth engineering against.
honest gaps
outages as gaps, not fabricated continuity
Extended outages stay visible as gaps in the verifier output rather than papered over with synthetic receipts; control-plane restarts catch up exactly one missed slot on boot. A daily offsite SQLite backup (Cloudflare R2) has its SHA-256 logged into the same anchored chain, so backup substitution is detectable. A public heartbeat exposes only the latest cycle's substrate metadata — proof the pipeline is live without revealing operator activity.
10
derived intelligence

what the anchored record computes

The anchored ledger settles what happened. Because it also records what each execution consumed and produced, that same record can be read as a graph rather than a flat list. The attack path and the remediation order below are computed from it deterministically, without asking the model to narrate its own work. It also holds the exact requests behind each finding's evidence, which is what a retest replays.

attack_graph
attack path reconstructed from the ledger
The tool_runs ledger, which the anchored chain commits to by digest, also records what each execution consumed and produced. Reading that forward yields a producer-to-consumer graph over the engagement's executions, where an edge means one step's output fed a later step's input. The attack path falls out of recorded evidence rather than the model's account of its own work. Ordering follows the ledger's insertion sequence, so the graph is acyclic and reflects true dispatch order. Edges are typed by the identifier that links two steps, such as a discovered host or a leaked credential, and weak or ubiquitous links drop below a strength cut so shared scope roots never manufacture a false dependency.
fix order
remediation ranked by leverage
Findings attach to the graph through the executions that produced their evidence, so one finding sits upstream of another when its evidence-producing step is an ancestor in the dependency graph. The platform counts how many downstream findings lie on paths through each one and orders the fix list by that count, giving a customer with a long findings list a defensible answer to which fixes remove the most exposure. The ranking is deterministic and carries no model judgment. It is labeled for exactly what it measures, the count of downstream findings on paths through a given one, not a promised break-set, since a guaranteed break-set needs dominator analysis the honest version does not yet claim. Findings whose evidence was paraphrased or came from manual traffic stay unranked instead of being forced onto the graph.
retest
a finding retested from its own evidence
finding_retest walks the same graph back from a finding: the executions its evidence cites, plus the earlier ones that produced values they consumed, each with the request the ledger recorded. The first call builds that plan and sends nothing. The condition that decides the verdict, what is present in the result while the issue exists, is committed to the audit chain before the first replay goes out, so it cannot be chosen after seeing the result. Every step dispatches through the normal path and meets the same gates as a new call. A replay that fails, or whose session the target rejects, is inconclusive and never reads as fixed. When a replay does read remediated, a sweep can re-run the non-mutating evidence steps with one delivery setting moved, such as HTTP/2 in place of 1.1 or the payload in the body instead of the query, which catches a fix that blocked the exact request and left the flaw in place. Login is never replayed; each step rides the identity's current captured session. disquiet_attest re-derives the verdict offline from the committed condition and the replayed outputs.
11
system properties

how the platform stays in scope and out of trouble

microVM
tenant isolation at the VM boundary
One microVM per user. Separate compute and storage, no module-level cross-tenant state. Each volume is encrypted at rest, with sensitive data sealed under a per-VM data key.
vantage
runners deploy where the work needs to happen
A runner is a single self-contained binary. Drop it on a laptop, inside a client network, or on a regional VM and it registers with the user's microVM. Runners are user-scoped; their engagement assignment is mutable at runtime.
oobd.io
out-of-band infrastructure included
Dedicated authoritative DNS resolver and a multi-protocol callback service at *.oobd.io on a dedicated IP. DNS, HTTP/S, LDAP/LDAPS, SMTP/SMTPS, FTP/FTPS, and SMB all attribute to the same minted token (FTP and SMB by correlation with the preceding DNS lookup), so a blind finding confirms on whichever channel the target actually reaches out over, not only the one the payload was aimed at. Blind injection, SSRF, XXE, and command injection get a live listener with one tool call. DNS rebinding is the same primitive. What the HTTP listener returns to a target that follows the callback is the operator's to shape (status, headers, body, or a per-request override), the token's DNS answers can be set per label including CNAME and MX, and coerced SMB and HTTP authentication is captured as NetNTLMv2 rather than logged as a bare hit. When the target's network can't reach the internet, collection moves to an HTTP listener bound on a connected runner's own segment and the hits land in the same chain.
gates
risky actions are bounded before they run
Every primitive declares, before dispatch, what it can do to the target (write, create, delete, spend a credential, submit failed logins, make the target send mail, hold load), how long that outlasts the call, and whether the platform can undo it. Where the reach is set by the caller's arguments rather than by the primitive, http_request among them, the dispatcher makes the caller declare it instead. A declared effect that doesn't fit the engagement's budgets and opt-ins is refused before dispatch. Afterwards the record says what was done, per object, whether the undo ran, and whether it was verified against the target. Anything left changed is named above the findings. Scope expansions queue as proposals the operator accepts in the dashboard.
halt
stopping an engagement reaches work already past the gate
When an engagement is stopped or deleted, its dispatches already in flight are cancelled, whether they egress from the VM or through a runner's tunnel, synchronous calls as well as background tasks. A connected runner switches off poisoning and relay for it and closes its local callback listener, and a reconnect does not re-arm either. The engagement's callback token is suspended, so whatever the operator planted on it, a programmed HTTP response or custom DNS, stops being served to a target that comes back after the stop. Deleting the engagement retires the token outright. Reactivation restores the runner toggles and resumes the token; a runner-local listener has to be armed again.
12
MCP surface

forty-three tools: the surface, not the product

The MCP surface is intentionally small: forty-three tools, no plugin loading, no runtime registration, and the model sees a flat list. Every security operation goes through two of them, needle("<op_name>", { … }) and its background-task variant scan_async, both resolving through the boot-validated registry in §2. The dispatcher runs locally in the VM by default, or routes through a connected runner for a non-VM vantage point (laptop, internal jump host, regional egress). Behind those two tools sit 190+ operations across 23 domains, listed in full at /capabilities.

  • Reconnaissance & Discovery
  • Authentication & Authorization
  • Injection
  • Client-Side Security
  • GraphQL
  • XXE
  • Deserialization
  • Cryptographic Attacks
  • Cache Attacks
  • Request Smuggling & Protocol Attacks
  • Race Conditions
  • gRPC
  • Mail Protocols
  • Headless Browser
  • Fuzzing & Brute Force
  • Cloud Security
  • SOAP / WS-Security
  • Kubernetes
  • AI Infrastructure
  • Network Protocols
  • Active Directory
  • Database Probes
  • Utilities
task lifecycle
background work has the same surface as foreground
scan_async returns a task_id; poll, cancel, and list outstanding tasks through the same surface. Long-running operations don't need a side-channel.
result spill
big outputs don't round-trip context
Large tool outputs return a handle the reasoner reads on demand. A 10 MB output never has to pass through the context window.
site map
surface accumulates across the engagement
Crawls persist their structured site graph instead of leaving it in the run that produced it. Pages, forms, endpoint literals lifted out of JS bundles, and the in-scope URLs nobody has walked yet read back as one projectable, cursor-paged map. The reasoner checks what's already known before walking a host again, so a second pass extends coverage rather than repeating it.
findings
structured insight ingest
insight creates findings with evidence, severity, and reproduction attached at creation. tool_runs lookups through findings_recent let the reasoner check coverage before re-running. finding_retest replays a finding's evidence path against the target as it stands now (§10).
13
integrations

external sources

Vulnerability intelligence syncs into a local CVE corpus so the reasoner works from current CVE data rather than the model's training cutoff. Asset and recon datasets are reached operator-side through their own MCP servers, not pulled into the platform.

NVD

Sync CVE records from the National Vulnerability Database into a local corpus with an operator-supplied API key. The reasoner works from current CVE data instead of its training cutoff.

14
who

Built by Erik Tayler. DDI → IOActive → Microsoft AI+R adversarial. Twenty-five years of varied target work — legacy banking systems, conversational AI platforms (XiaoIce, Tay), image classifiers (Advertising, SafeSearch), and one memorable Tandem Van de Graaff accelerator.

email erik@disquiet.ai · signal disquiet.01 · linkedin in/tayler
anchored 1m ago · btc ✓ · freetsa ✓ · digicert ✓ · rekor ✓