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).
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.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, confirmation gates, and orthogonal-axis emit design.
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 harness-level enforcement point the model cannot prompt its way around, because none of them are model-side.
mcp_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.<tool_output> 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. Target-controlled strings are wrapped at the leaf primitive that read them rather than at the harness edge, so the boundary holds when a value gets carried through summarization or re-framed by a later tool. Probes asking the model to dump its system prompt or list its tools get a fixed dispatcher-side response, no model round-trip. Verbatim findings (secrets, tokens, port lists) are pinned through history summarization so they survive byte-for-byte.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? There's no test suite covering the catalog. What changes 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, 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.
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 such pass took the catalog from 183 primitives to 169 — a 7% reduction — 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.
Primitives can surface orthogonal axes — not just collapsed verdicts. Many primitives have simple serialized emits because the shape of their data warrants it; the platform doesn't force structure where none exists. But where the data has real multi-axis structure, the primitive surfaces it that way rather than flattening to a single verdict. cors_probe(url) illustrates the pattern: five origin-variant probes plus a preflight channel, scored on axes that answer genuinely different questions. Origin acceptance and credential willingness together decide whether an attacker origin can read a response. The preflight decides something else — whether it can send a request the server will process, whose response it may never be able to read. The reasoner sees each axis as its own field, never fused into a single "vulnerable: yes/no" verdict.
Probe variants (one HTTP request each)
| Label | Origin sent | What it tests |
|---|---|---|
evil_origin | https://evil.com | Arbitrary external origin |
null_origin | null | Sandboxed-iframe origin |
subdomain_prefix | https://evil.{host} | Origin check uses startswith() |
subdomain_suffix | https://{host}.evil.com | Origin check uses endswith() |
http_downgrade | http://{host} | Scheme not enforced |
preflight | https://evil.com (OPTIONS) | Whether the preflight path's origin policy diverges |
Read axis — origin acceptance × credential willingness
| ACAO | ACAC | Verdict | Score | Why |
|---|---|---|---|---|
| reflected (== origin) | true |
CRITICAL | 9 | Credentialed cross-origin read — direct cookie / bearer exfil from any page on the internet. |
| reflected (== origin) | absent / false | HIGH | 6 | Reflected but no creds — still readable cross-origin; many APIs leak sensitive data here. |
* (wildcard) |
true |
LOW | 2 | Browsers require an exact origin match once credentials are in play, and reject this pair outright. 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 |
HIGH | 6 | Non-simple requests (custom headers, PUT/DELETE) are delivered with the victim's cookies attached — cross-origin state change. |
| approves the origin, no credentials | MEDIUM | 4 | Same delivery, no cookies. The response may still be unreadable; the state change already happened. |
* (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. The spec excludes it from the Allow-Headers wildcard, so every single-page app whose API lives on a separate host 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 flags correct configuration across a large share of real deployments, on preflights the browser was going to discard anyway.
Per-probe rows, from the emit's summary
[CRITICAL score=9] evil_origin — ACAO: 'https://evil.com' | ACAC: true | browser: credentialed read
[ok ] null_origin — ACAO: '' | ACAC: absent | browser: no cross-origin read | HTTP 200
[CRITICAL score=9] subdomain_prefix — ACAO: 'https://evil.tar.com' | ACAC: true | browser: credentialed read
[ok ] subdomain_suffix — ACAO: '' | ACAC: absent | browser: no cross-origin read | HTTP 200
[ok ] http_downgrade — ACAO: '' | ACAC: absent | browser: no cross-origin read | HTTP 200
Pre-flight (OPTIONS, Origin: https://evil.com): HTTP 204
ACAO : 'https://evil.com' | ACAC: true
Allow-Headers : Authorization
Allow-Methods : GET,DELETE
browser : credentialed read
[HIGH score=6] preflight approves https://evil.com with credentials
→ non-simple requests (custom headers, PUT/DELETE) are delivered with the
victim's cookies attached — cross-origin state change
→ whether the response is readable is decided separately by the ACAO/ACAC
axes
→ Authorization is permitted on this channel
Verdict: cross-origin exposure — max score 9, 3 browser-reachable findings
One entry from the emit's findings array
{
"label": "subdomain_prefix",
"origin": "https://evil.tar.com",
"acao": "https://evil.tar.com",
"acac": "true",
"with_credentials": true,
"browser_outcome": "credentialed",
"verdict": "CRITICAL",
"score": 9,
"issue": "Origin reflected with Allow-Credentials: true — …"
}
Why orthogonal axes matter. If subdomain_prefix succeeds with ACAC=true but subdomain_suffix does not, the origin check is using startswith(), not endswith() — a conclusion no fused verdict could carry. The preflight scores on its own because it answers the send-side question rather than the read-side one: an approved preflight delivers state-changing requests whether or not the response can ever be read back. And when it approves an origin the actual-request path rejects, OPTIONS is being served by a different handler than the request it gates — a divergence visible only because the two are measured separately. A "CORS: vulnerable" verdict would destroy all of it; the reasoner needs the axes separated to pick the exact bypass that fits.
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.
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.
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, proposing a scope expansion, attaching a captured session to a primitive. 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 header values redacted at the tool boundary. Names pass, values 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.
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.
start_tls hangs the TLS handshake mid-MemoryBIO). 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.event_id for cursor-based replay through Last-Event-ID; when eviction has rolled past a requested cursor, the stream surfaces a disquiet-cursor-lost marker and the dashboard rehydrates from REST.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); merges are authoritative with Set-Cookie winning over request Cookie.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; a 401 response marks the row stale, and 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.as_session=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.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, values mask to ***. The reasoner can reason about which auth mechanism is in play without ever holding the secret.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.
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; it never transits the control plane.
Single-cloud dependency today. Daily encrypted backups land on a second independent provider, so audit evidence survives a vendor outage even when service does not. Cold-standby failover across providers is the planned mitigation when operator demand justifies the operational cost.
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 across four trust models: 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 offline, against pinned roots, with zero network calls to Disquiet. Designed against the audit-log baselines in SOC 2 CC7.2, ISO 27001 A.12.4, PCI DSS Req 10, and NIST 800-53 AU.
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.(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.insight ingest, and emits runner.tool_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.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.
tool_runs ledger the verifier walks 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.*.oobd.io on a dedicated IP. DNS, HTTP/S, LDAP/LDAPS, SMTP/SMTPS, FTP/FTPS, and SMB all answer on the same minted token, 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. When the target's network can't reach the internet, collection moves to a listener bound on a connected runner's own segment and the hits land in the same chain.
Every operation runs as needle("<op_name>", { … }) or its async variant. The dispatcher runs locally in the VM by default, or routes through a connected runner for non-VM network vantage points (laptop, internal jump host, regional egress). New primitives land regularly as live engagements surface capability gaps. The full operation reference is public at /capabilities.
The MCP surface is intentionally small — forty-one tools, no plugin loading, no runtime registration, validated at boot against a single registry. The model sees a flat list.
needle(op, args) resolves every operation through the boot-validated registry. scan_async(op, args) runs the same dispatch as a background task and returns a task_id.*.oobd.io***.as_session=. TOTP codes generate from stored seeds without the reasoner holding the secret.insight creates findings with evidence, severity, and reproduction attached at creation. tool_runs lookups let the reasoner check coverage before re-running.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.
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.
Built by Erik Tayler. DDI → IOActive → Microsoft AI+R adversarial. Twenty years of varied target work — legacy banking systems, conversational AI platforms (XiaoIce, Tay), image classifiers (Advertising, SafeSearch), and one memorable Tandem Van de Graaf accelerator.