Written for someone who will try to break it. It covers the four boundaries that matter: outbound calls to agent endpoints (SSRF), the signing boundary, private key handling, and what the LLM is and is not allowed to do.
The threat that shapes the design: an ERC-8004 identity costs a few cents to register, and its service URL is attacker-controlled. Every agent endpoint Marque fetches is hostile input. The system that fetches it also runs Postgres, Redis and four other products on the same VPS.
packages/probe/src/safe-fetch.ts is the only path from Marque to an agent
endpoint. The probe worker, the conformance runner, the identity resolver and
execution all go through it. There is no fetch(agentUrl) anywhere else — a test
(safe-fetch.test.ts, redirect.test.ts) and code review enforce that.
What it does, in order:
- Resolve DNS first, check every A/AAAA record. A hostname that resolves to a private range is rejected before a socket opens. If a host round-robins between a public and a private address, that is a coin flip we refuse — all resolved addresses must pass.
- Denylist of reserved ranges, not an allowlist of public ones. Blocked:
0.0.0.0/8,10/8,127/8,100.64/10(CGNAT),169.254/16(link-local, including169.254.169.254),172.16/12,192.0.0/24,192.0.2/24,192.88.99/24,192.168/16,198.18/15,198.51.100/24,203.0.113/24, everything>= 224. IPv6:::,::1,fe80::/10,fc00::/7,ff00::/8,2002::/16(6to4),64:ff9b::/96(NAT64). - IPv4-mapped IPv6 in both notations.
::ffff:169.254.169.254and::ffff:a9fe:a9feare the same address; Node's URL parser rewrites one into the other. The guard decodes the embedded v4 from either form and re-checks it. This was caught by the redirect test, not by reading the code — it is called out because it is exactly the kind of bypass that ships. - Connect to the addresses that were checked, not to the name. Checking DNS
and then calling
fetch()resolves the host twice: an attacker who controls the authoritative server can answer public the first time and127.0.0.1the second, and the guard validates an address the socket never uses. The request now carries alookupthat returns only the already-validated addresses, so the connection cannot land anywhere the check did not clear. TLS still verifies against the hostname, so pinning the address does not weaken the certificate check. - Re-validate after every redirect.
redirect: 'manual'; each hop runs the full check again. A public host that 302s to169.254.169.254gets nothing. Max 3 redirects. Request headers are dropped on a cross-origin hop so a redirect cannot forward anauthorizationheader to a third party, and a303(or a301/302on aPOST) becomes a bodylessGET, as a browser would do. URLs carrying inline credentials (https://user:pass@host/) are refused outright. - Blocked ports regardless of address: 22, 23, 25, 445, 3306, 5432, 6379,
9200, 11211, 27017.
httpandhttpsschemes only. - Hard 8s timeout via
AbortController, covering DNS as well as the socket. - 512 KB response cap, enforced while streaming. A
content-lengthover the cap is refused without downloading; a lyingcontent-lengthis caught when the stream passes the cap and the reader is cancelled.
Failure is a typed result (blocked_ssrf, dns, tls, timeout, too_large,
…), never an exception that a caller might swallow. A blocked fetch is recorded
as a failed probe with the reason — visible on the agent's profile.
Not claimed: this does not defend against a genuinely public endpoint that is
itself malicious in its response. That is handled downstream — responses are
parsed as data, size-capped, and never eval'd or used to build a shell command
or SQL. Agent output that fails schema validation is a failed probe, not a crash.
Three signers, and nothing crosses between them:
| Signer | Holds | Signs | Never signs |
|---|---|---|---|
| The buyer's browser wallet | the buyer's key, in the extension | exactly one thing: the charter grant (allowlist of contracts + spend cap + expiry) | anything else — there is no "approve all", no session key handed to a server |
| The operator key (server-side) | in /root/.marque/secrets.env, chmod 600, outside the repo |
receipt anchors and pre-outcome seals on MarqueRegistry, BSC testnet only | anything on mainnet, anything that moves buyer funds |
| The proof-run deployer key | never at rest — passed inline as PROOF_PK=0x… node scripts/pancake-proof.mjs for one run, then gone |
the one-off mainnet rebalance sequence, under a $60 self-imposed charter scope | reused for anything else; it is not wired into any service |
The charter is enforced before a signature is requested, not audited after.
packages/mandates checks the intended call against {allowlist, capUsd, expiresAt} and refuses to compose a transaction that a call to a non-allowlisted
contract, a spend over cap, or an action past expiry would produce. Revocation is
one transaction, immediate, on chain.
The operator key runs on testnet on purpose. Seals and anchors are a mechanism,
not a value transfer; the mechanism is identical on any EVM chain, and a testnet
seal costs nothing for a judge to reproduce. The one thing that had to be
mainnet — a rebalance with real money at risk — used the deployer key for a
single documented run (docs/pancakeswap-proof.json, docs/DEVIATIONS.md D9b).
- All secrets live in
/root/.marque/secrets.env, mode600, owned by the deploy user, outside the git tree..gitignorecovers.env*; a pre-existinggit secrets-style grep runs in CI. No key, mnemonic or RPC token is in the repo or in any build artifact. - The operator keystore is a Web3 Secret Storage V3 JSON, scrypt
N=131072, decrypted at process start from a passphrase insecrets.env, held in memory only. - The deployer key for the mainnet proof was passed as a process env var for
the duration of one script invocation and never written to disk, never logged
(the script redacts
PROOF_PKfrom any error dump), never committed. BSC_ARCHIVE_RPC_URL— a QuickNode BSC archive endpoint, used only by the historical benchmark replay (packages/ledger/src/runner.ts→archiveClient()). The URL contains its own access token, so it is treated like a key:secrets.envonly, never committed, never logged, never in aNEXT_PUBLIC_*var or any client bundle. Ordinary marketplace reads keep using the pooled public dataseeds; only a task that pins an out-of-window block touches the archive path.- Rotation: the VPS was root-compromised via SSH brute-force in early Sep
2026 (see below). Every key that touched the old host is considered burned and
was regenerated on the rebuilt host: new operator wallet, new agent keystores,
new RPC tokens. The old ERC-8004 registrations were re-done from the new keys
(
docs/DEVIATIONS.mdD10.5C-05). The compromise post-mortem and the current hardening (fail2ban, ufw default-deny, key-only SSH, immutableld.so.preload, a 20s watchdog service) are indocs/RUNBOOK.md.
One rule, stated three ways because it is the one that would matter to a review:
The LLM never signs and never prices.
- The only model call on any path is category classification (DeepSeek):
given an agent's metadata, which of four categories does it belong to. It runs
in the
classifyworker, under a hard per-run USD cap, and its output is a category label and nothing else. It cannot cause a transaction, set a fee, or change a number a buyer sees as measured. - MCS (the conformance standard) is deterministic arithmetic. It checks facts and compliance with a supplied policy — did the agent's numbers survive recomputation, did it stay inside the constraints it was given. No model is in that loop. Judgement about whether an agent is good goes in the Ledger, which is blind-graded by a human against a rubric registered before the task was run — not by a model.
- Agent responses are parsed as structured data against a schema. They are never interpolated into a prompt that then drives an action, never used to build a query or a command.
- CSP with
frame-ancestors 'none',object-src 'none',upgrade-insecure-requests;connect-srcis an explicit allowlist (self, the named BSC RPC hosts, WalletConnect).'unsafe-eval'has been dropped fromscript-src— nothing in the production bundle needs it, and leaving it in hands an injected string a way to become code.script-src/style-srcstill carry'unsafe-inline', a known Next.js App Router limitation that a nonce would close; not yet done, and tracked rather than glossed. - HSTS,
Cross-Origin-Opener-Policy, a tightenedPermissions-Policy. - Rate limiting on
/api/v1/*and the write endpoints. /api/v1/events(anonymous telemetry) is name-allowlisted, drops any meta key outside a fixed set, caps string and number values, and always returns 204 — it cannot be used to store arbitrary data or to probe for what exists.- No user accounts, no sessions, no cookies with meaning. The only state a buyer has is on chain.
Security contact: the address in README.md. First-party observations (probe
history, conformance results, run artifacts, seals) are append-only by
policy — there is no code path that deletes one, which also means an attacker
who got write access could not quietly rewrite a track record without it showing
in the anchor chain.
Audited 9 September 2026 (pnpm audit --prod), recorded rather than summarised
away.
Patched. Next.js was pinned at 15.1.3, which predates the August 2026
security release. Production runs 15.5.25 (React 19.2.6) as of 9 September
2026: full test suite green, lint clean, and scripts/verify-candidate.mjs
green on the candidate before cutover and again on production after — 27
route/width combinations with no console error, no CSP refusal and no real
horizontal scroll, plus the wallet modal opening under the tightened
script-src.
The build was verified ahead of the cutover rather than during it.
scripts/build-web.sh with MARQUE_BUILD_DIR builds a candidate into its own
directory, so the running server's static assets are never removed underneath
it; the candidate is then exercised on an alternate port against the live
database and swapped into place by rename. The previous build is kept at
apps/web/.next/standalone-prev, so a rollback is two renames and a restart.
Present and not patched, with the reasoning stated:
drizzle-orm<0.45.2 — SQL injection via improperly escaped identifiers (high). Marque is on0.38.x. The advisory concerns identifiers — table and column names interpolated into SQL. Marque never builds an identifier from input: there is nosql.identifiercall anywhere in the tree, and the singlesql.rawcall site (packages/probe/src/worker.ts) interpolates acaseexpression generated from a hard-coded constant array, with no request data on the path. Every other query parameterises its values. The upgrade crosses seven minor versions of the data layer that every worker and page depends on, so it is deliberately deferred past the judging window rather than taken unverified. Exposure is believed nil; that belief is the reason, and it is written down so it can be checked rather than trusted.postcss,ws,uuid,axios(moderate/high). Build-time tooling and transitive wallet-library dependencies, not code paths that serve a request. Tracked for the same post-window upgrade.
The audit JSON behind this section is regenerable with
pnpm audit --prod --json; nothing here is a summary of a summary.