Skip to content

When something looks wrong

Seal prints tokened refusals — a token is a fixed snake_case name for what happened — and un-tokened errors such as seal: usage: seal verify PATH. This page lists the tokens selected by the source patterns in test/guide-tokens.test.mjs, grouped by where you meet them, with the cause and the way out. A test in this repository (test/guide-tokens.test.mjs) compares the token headings with tokens matched by its configured source patterns, which omit some emitted tokens, including client_elicitation_unsupported, receipt_correlation_capacity_exceeded, and receipt_signer_absent.

Refusal output includes the four shapes below, plus un-tokened seal: <message> errors, seal: REFUSE <token>: <message> protection errors, and REFUSED <token> lines with a separate message when supplied:

  • as the protected tool’s error result in Claude Code: approval refused: <token> — <detail>
  • from a seal command on stderr: seal: <token>: <message>
  • from the wrapper as Claude Code starts the protected server (visible in Claude Code’s MCP logs): seal __proxy: <token>: <message>
  • as a plain refusal line from the installer, the installed launcher, the checker, or seal doctor: REFUSE <token>: <reason>

A refusal means Seal did not complete the action at the point named by that token. It does not prove an earlier step changed nothing: for example, an installer or a failed external command can have made partial changes first.

Minted in contract/contract.cjs and spine/proxy.cjs; delivered as the tool’s error result. The first group is the approval contract judging a retry; unless a token says otherwise, the way to proceed is simply to make a fresh call and approve it fresh.

You saw: approval refused: already_consumed — this one-use approval has already been consumed. The same approval was presented a second time; the call did not run again. This is the replay protection holding. If you wanted the effect twice, call twice and approve twice. If you never approved a first time, read the receipts before doing anything else.

You answered Decline at the prompt. The call did not run, and this request can never be revived by re-sending the same approval — see terminally_declined.

A retry arrived for a request you already declined. Denial is terminal per request: nothing you declined can be re-approved later by replaying it. A fresh call gets a fresh prompt.

You answered Cancel (rather than Decline) at the prompt, and a later retry referenced that cancelled request. Make a fresh call.

More than 2 minutes passed between Seal issuing the approval handle and evaluating the retry, so the window closed. Exercised for real: after a 125-second wait the retry was refused with expired — the approval window closed before the retry arrived. Approval expiry follows the local wall clock. Call again and answer within the window.

The approval prompt was issued before a restart (of Claude Code, and with it the wrapper), and the answer arrived after. Pending approvals deliberately do not survive a restart. Call again in the new session.

The retry carried an approval handle Seal never issued — an altered handle and a never-issued one are indistinguishable by design, because the handle carries no information to tamper with. Make a fresh call. If this happens without anything unusual on your side, something rewrote the client’s traffic; that is worth investigating.

The retry’s requestState is not even the shape of a handle Seal issues. Same standing as unknown_state.

The retried call’s arguments differ from the exact arguments shown at the prompt — exercised for real by slipping an extra argument into an approved call. The approval covers the exact effect you saw, so the changed call was refused. If the new arguments are what you want, call with them and approve what the prompt then shows.

Defensive: the retry names a different tool than the one the approval was bound to. In the shipped gate only the guarded tool’s calls reach the contract, so this token was not reachable in our runs; it exists so the binding is enforced in depth.

Defensive, like tool_altered: the retry claims a different project or server than the approval was bound to. Not reachable through the shipped gate, which pins both.

The approval answer did not have the expected shape — for example, an accept with no readable approve value. Usually a client defect rather than a user action. Call again; if it repeats, the client is not returning the form faithfully.

Node and the WASM kernel gave different answers to an authorization row. The detail names the side that refused. Seal fails closed and does not consume or forward the call. Preserve the receipt and report the disagreement; retrying without understanding it is not a remedy.

The installed files no longer match the fixed install record, an unrecorded file appeared, or the record changed after this wrapper started. The approval was refused. Reinstall and restart the protected session. Matching disk bytes at a check does not establish which bytes the process already loaded.

No trustworthy installed-tree record was available at the approval check. Missing or unreadable records and source-checkout wrappers cannot authorize protected calls. Complete a verified installation and restart the session; the wrapper never creates or adopts a replacement record during approval.

The vendored WASM is missing, unreadable, or its SHA-256 does not match the published pin. Seal does not fall back to JavaScript authorization. Restore the installed artifact from bytes that match the trusted distribution pin.

The runtime manifest is missing, unreadable, malformed, or lacks a valid WASM pin. Restore a complete pinned installation; do not invent a replacement hash.

The isolated kernel worker could not start, exceeded its 30000ms execution deadline, seal_init rejected its signed configuration, or kernel execution failed. Nothing forwards. A deadline refusal includes the exceeded limit in milliseconds, which distinguishes a hung worker from other execution failures. Preserve the full detail and report it; this may expose a slow or damaged runtime, or an incompatible kernel/config boundary.

The kernel worker returned unreadable output or a verdict other than ALLOW or BLOCK. Seal treats silence and malformed output as denial. Preserve the output detail and report it.

Seal refused to ask for approval because the effect exceeded Seal’s own rendering rules — an argument line was too wide, the message needed more lines than Seal permits, or an argument had no canonical form (a non-finite number or one outside the supported numeric range). Decimals such as 1.5, coordinates, and scientific notation are supported. Passing those rules does not guarantee that the client paints every message-body line: in the recorded Claude Code 2.1.251 dialog it folds three of six lines and still paints the Accept button. The current schema description carries every argument, the full scope with TTL, and the outside-Seal boundary in the channel that recording paints; the new layout is not yet recorded. Seal does not take the protected server down to refuse a value the receipt writer will not seal. The tool call is refused; nothing ran. If you control the arguments, make the complete message fit the display limits; otherwise this tool’s calls cannot be interactively approved.

The server’s .mcp.json entry changed while the gate was up, so forwarding stopped — for the guarded tool and everything else on that server. seal status will show DRIFTED; the ways out are on the status page.

The recorded protection state file disappeared while the wrapper was running (exercised by removing it mid-session). Seal fails closed: nothing forwards without the record. If you deleted Seal’s data directory, seal unprotect refuses with no_seal_owned_override because its ownership proof is gone; stop Claude Code, inspect the server’s local override and remove it manually only after confirming it is the Seal wrapper, then protect again; if you did not delete the directory, find out what did.

The protected server’s command no longer exists — its file was deleted or moved since protect. Seen at wrapper start as seal __proxy: protected_server_missing: protected server command is missing: <path>, or mid-session as a tool refusal. Restore the server (or unprotect, fix .mcp.json, and protect again).

The protected server’s process failed to start for a reason other than the command being missing (permissions, for example). Not reached in our runs — the failure modes we provoked surfaced as the server exiting instead. Fix whatever stops the server starting; the gate refuses to forward until it does.

A defensive fallback: a pre-forward check refused without naming a token. The shipped checks always name one (project_server_drifted, state_absent), so meeting this token would itself be worth reporting.

Minted in spine/protection.cjs; ProtectionError instances print as seal: REFUSE <token>: <message>, while ownership refusals print REFUSED <token> with a separate message when supplied.

Arguments missing: seal protect SERVER TOOL and seal unprotect SERVER need their names. (Running seal protect without arguments prints seal: REFUSE usage: usage: seal protect [--timeout-ms MILLISECONDS] SERVER TOOL[?ARG=SCALAR|?ARG~"PATTERN"] [TOOL...] and exits with code 1.)

There is no .mcp.json in the project, or it has no server by the name you gave. Run in the project directory, and spell the server exactly as .mcp.json does.

.mcp.json exists but could not be used: not valid JSON, or the named server entry is malformed (a non-array args, a non-object env, a missing command). The message names the specific problem; fix the file.

A launch field in .mcp.json references an unset environment variable without a fallback. Set the named variable in the environment used to run Seal and Claude, or provide ${VAR:-default}, then retry. Seal refuses to launch the literal placeholder. Resolved launch values are bound into protection’s drift detection; changing them requires unprotecting and protecting the server again.

A launch field contains a placeholder outside Seal’s supported ${VAR} and ${VAR:-default} syntax. Use one of those forms or a literal value, then retry. Expansion happens once against the launching environment, not against other entries in the server’s env object. A set but empty variable stays empty.

The named server is http (or anything but stdio). Seal gates local stdio servers only — there is no local process to stand in front of otherwise.

Seal could not start the configured server while checking its tools, or the server exited before initialization began. Check the command, permissions, environment, and any stderr included in the message, then run seal protect again. The same check runs when the wrapper activates; a failure there marks the protection state BROKEN.

The configured server did not complete the MCP initialize exchange: it may have timed out, exited, rejected the request, or returned malformed output. Fix the server or increase the discovery limit with --timeout-ms, then run seal protect again. At wrapper activation this failure marks the state BROKEN.

The configured server did not return a usable, complete tools/list: it may have timed out, exited, rejected the request, returned malformed output, or repeated a pagination cursor. Fix the server or increase the discovery limit with --timeout-ms, then run seal protect again. At wrapper activation this failure marks the state BROKEN.

The server’s complete tools/list contained no named tools, so there is nothing Seal can guard. Fix the server’s tool registration and protect again. At wrapper activation this failure marks the state BROKEN.

The server answered tools/list, but the tool named on seal protect was not among the observed names. Check the spelling or fix the server so it exposes that tool, then protect again.

The claude command is not on PATH. seal protect installs the gate through Claude Code, so it needs it. Install Claude Code or fix PATH, then re-run.

Claude Code already has a local-scope override for this server name — maybe an earlier experiment, maybe something else using the same mechanism. Seal will not overwrite it. Look at it with claude mcp get <server>; remove it with claude mcp remove --scope local <server> if it is yours to remove.

Seal could not read or parse the local Claude Code configuration; the refusal names the underlying error, and no configuration was changed.

Seal read the local Claude Code configuration and found that the server’s local override is not the definition Seal installed. Restore that definition before trusting status or asking Seal to remove the override.

Seal has no stored ownership proof for this server’s local override, so it will not remove or reinterpret that override.

Several server records exist, so seal recover --archive cannot choose one. Run seal recover --archive SERVER for the intended incompatible record.

The same server has both a legacy project record and a new server record. Seal refuses to choose between two state histories. Stop the wrappers and resolve which record and installed override are authoritative before retrying; Seal has not copied, deleted or combined either history.

The requested server already has recorded protection (the message names the server and its state, e.g. server "db" is already PENDING RESTART). To change that server’s tool set, unprotect it first. Protecting another server in the same project leaves the first server protected.

seal unprotect found a live Claude Code session still running the wrapper. Stop that session first. Taking the gate down under a live session is exactly the kind of silent change Seal exists to prevent.

The proxy’s durable lease generation changed while it was evaluating an approval. Seal rechecks the proxy’s lease immediately before consuming the approval and refuses without consuming it if that check fails, but after that check passes and the approval is consumed, a lease mismatch at the subsequent pre-forward check refuses the forward with the approval’s one use already spent.

The claude mcp add step reported failure, and Seal recorded the state as BROKEN with that reason. Fix the external cause, then retry seal protect if no local override was installed. Seal checks the actual local configuration and refuses retry while a wrapper lease is live.

If the command installed the Seal override before reporting failure, stop Claude Code and run seal unprotect <server> before protecting again. Unprotect checks the actual override against the recorded ownership definition, including for older failed-install records. A foreign replacement or unreadable local configuration still refuses; inspect that configuration and Claude Code’s error before making manual changes. An absent failed override has nothing to remove, so use the protect retry path.

Unprotect can remove an owned, inactive override even if the project’s .mcp.json is missing or invalid JSON. Its output reports the source observation before and after removal. Readable files have byte hashes; unavailable files have null hashes, which do not establish that unreadable contents are unchanged.

The claude mcp remove step failed during unprotect — most simply because Claude Code could not remove the local override. Stop Claude Code and inspect the actual override and Claude Code’s error before making any manual change. Do not create an arbitrary replacement: Seal removes only the installed override whose definition matches its recorded ownership proof.

The recorded state has a schema this Seal binary cannot interpret. Seal accepts the supported seal.protect/v1 spellings, including older single-tool records; the creating binary’s sealVersion is provenance, not a compatibility gate. Unsupported schemas still refuse.

Seal includes seal recover. It requires exactly one argument, --archive.

The current binary also provides an explicit recovery command: stop Claude Code and run seal recover --archive in the affected project. It preserves the exact incompatible state at the printed archive path before removing Seal’s owned local override. The route is then outside Seal; review the archived server, tools and predicates and run seal protect SERVER TOOL [TOOL...] with your chosen selections. Restart Claude Code and run seal status to check activation. Recovery works locally without downloading another binary, using the installed Claude CLI when an override needs removal.

seal recover --archive found compatible state or no state. Recovery is only for incompatible state; it made no state or configuration changes. Run seal status to inspect the current route. To remove compatible protection, stop Claude Code and run seal unprotect SERVER.

The incompatible state does not establish this project’s identity and server name. Recovery refuses to guess ownership and made no state or configuration changes. Inspect the recorded state and the actual Claude Code local override before deciding what manual repair is needed; do not invent ownership metadata.

The stored state changed while recovery was in progress. Stop Claude Code and other commands changing this project’s protection, then retry seal recover --archive. The archive already written is retained. Recovery may have removed the owned override before detecting the change, so inspect the actual override and state before restarting the client.

As Claude Code starts the protected server

Section titled “As Claude Code starts the protected server”

Printed by the wrapper on stderr, visible in Claude Code’s MCP logs as seal __proxy: <token>: <message>. protected_server_missing and incompatible_state (above) can also appear here.

Another Seal proxy already owns this project’s protected route. The second starter is refused with the holder pid and lease generation. Stop that session, or let it exit and retry; a crashed owner is recoverable when its PID and process-start witness are no longer live and the next holder takes the next generation. The same token also names a project lock held by another Seal operation: a seal protect, seal unprotect or seal recover run, or another wrapper’s own record check or lease commit. That refusal says retry after that operation finishes; tool discovery runs outside the lock, and a starting wrapper waits up to 3200ms for each lock acquisition before refusing. At that bound the message names a lock-acquisition timeout and the unfinished operation, which may be waiting for a slow subprocess; it does not establish that another session owns a live lease. This is a transient start event, not a persisted project status.

Another Seal operation holds this installation’s lifecycle lock. Startup waits up to 3200ms for each lock acquisition, then refuses with a message naming the installation lock and its live holder PID. Let that operation finish and retry. This does not mean another wrapper owns the route’s session lease.

Seal cannot validate or acquire the installation’s lifecycle lock. A stale owner, malformed lock record or unavailable process-start witness requires inspection; startup does not remove the lock or wait out this refusal. Check the named lock and operation before retrying. A live PID with a different process-start witness is stale, even if that PID still exists.

The stored protection record changed while the wrapper was discovering the server’s tools: the server was unprotected, or the command, environment, .mcp.json digest, journal, receipts directory, discovery timeout or protected selections it was validated against are no longer the ones stored. Discovery runs outside the project lock, so such a change is possible, and it is refused at the lease commit: no lease was taken and nothing was written. Run seal status, then restart Claude Code so a new wrapper validates the current record. This is a transient start event, not a persisted project status.

Seal raises this token when a protected state operation cannot establish a process-start witness for a live process. The error message names the specific situation and states the correct remedy. The approval-journal lock path runs on any platform, including macOS. On Linux x86-64, Seal reads /proc/<pid>/stat; it refuses with process_witness_unavailable if it cannot read that file, if the stat record is malformed, or if the process-start field is absent. On macOS x64/arm64, the stored lease and project-lock path uses the native process-start witness helper. That path reports helper or witness failures with Darwin-specific refusal tokens.

The .mcp.json server entry changed between sessions, discovered at wrapper start: seal __proxy: drifted: project server drifted before proxy activation. The server does not start; seal status shows DRIFTED. Ways out are on the status page.

The recorded state file exists but cannot be read (exercised by corrupting it). Seal will not gate on a record it cannot read. If you have no explanation for the damage, treat that seriously; the blunt recovery is to remove the broken state and the local override and protect again.

The guarded tool existed at protect time but was absent from tools/list when the wrapper tried to activate. Seal marks the protection state BROKEN instead of starting a gate for a different tool set. Restore the tool, then remove the broken state and local override and protect again.

seal doctor found an auto-response hook configured (SEAL_ELICITATION_AUTO_RESPONSE or CLAUDE_ELICITATION_AUTO_RESPONSE), so approval prompts in this environment may be answered by software, not by you. No approval should be trusted until the hook is removed.

The receipt JSON repeats an object member name. Duplicate names make the meaning parser-dependent, so the checker refuses before validation.

The signature object contains one or more members that the checker does not allow. The checker refuses the receipt and names every member it did not allow; the signature object must contain exactly algorithm and value. Remove every named member and obtain a new valid receipt before you run the checker again.

The receipt contains a non-finite number or a value outside [-9007199254740991,9007199254740991]. Decimals are supported within that range and retain their parsed JSON numeric value. Integer-only fields, such as timestamps, keep their own validation rules.

Canonicalisation encountered a value outside JSON’s null, boolean, string, supported finite-number, array, and object forms. The checker does not coerce it.

The receipt supplies non-empty grants or forecasts, which the current kernel does not consume. They must be empty so signed-but-ignored input cannot appear to affect replay.

Receipt refusals use the same tokens whether you invoke the installed seal verify command or the standalone v2 checker. The producer, command, and checker all use seal.receipt/v2; there is no second receipt format to select.

Printed by the installer, the installed launcher, and the demo alike for Seal v0.4.0. Seal supports install, demo and receipt checking on Linux x86-64 and macOS x64/arm64. Protect is supported on Linux x86-64 and macOS x64/arm64; macOS Protect execution is not exercised in CI. Windows, Linux ARM and other unsupported installations refuse without changing files.

The install artifact could not find node on PATH. Seal requires Node 20 or newer. Seal supports install, demo and receipt checking on Linux x86-64 and macOS x64/arm64. Protect is supported on Linux x86-64 and macOS x64/arm64; macOS Protect execution is not exercised in CI.

Two version records that must agree do not — the installed VERSION file against the install record, or package.json against VERSION in a build. A healthy install never shows this; it means a mixed or hand-edited installation. Reinstall from a fresh artifact.

The installer checks the artifact before it runs it, but some later checks occur while extraction is writing the store. A refusal here can therefore follow partial store writes; remove the failed install and reinstall from a verified artifact. Minted in scripts/install.cjs.

Seal could not create, replace, or clean up an installer target because its parent directory is not writable. Check that the selected prefix and its bin and lib/seal parents are owned and writable by the installing user; then repair those permissions or choose a different prefix and rerun the verified artifact.

Seal found an existing install target but cannot inspect or read it because the installing user lacks permission. Do not force past that boundary: repair the target’s ownership and read permission if it is your install, or select a fresh prefix that you control and install there.

The selected prefix already contains incomplete, non-regular, unreadable, or non-verifying Seal install targets, or store content that differs from the artifact being installed. Do not overwrite it blindly. Choose a fresh prefix, or repair the existing install only after determining why it no longer verifies; then rerun the verified artifact.

A Darwin artifact build did not receive the native process-start witness helper from its matching release runner. No artifact was packed. Compile the helper on that runner and pass its path with --macos-helper.

The supplied native process-start witness helper is not a thin Mach-O for the Darwin artifact’s named architecture. No artifact was packed. Compile it on the matching runner and check the helper against that platform before retrying.

You ran the artifact without --sha256. The pin is required, on purpose: you state the digest of the bytes you meant to install, from where you got them, and the installer confirms it.

The --sha256 value is not 64 lowercase hex characters. Copy the published digest exactly.

The installer takes only --sha256, --prefix, and --bytes; anything else is refused rather than ignored.

The file is not a built release artifact — no payload, a damaged header, or trailing bytes. Re-download it.

The artifact ends before its payload does (an interrupted download, as exercised by cutting a real artifact short). Re-download it.

The artifact’s bytes do not match the --sha256 pin, or an inner file does not match the payload manifest. Either the download is corrupt or the pin is for a different artifact. From the installed launcher, the same token means an installed file no longer matches the install record — the store has been modified since install (exercised by editing one installed file). Reinstall.

A file the record promises is not there — the installer’s payload lacks a required file, or (from the launcher) an installed file has been deleted from the store. Reinstall.

An installed file exists but cannot be read (permissions, for example). The launcher refuses to run a store it cannot fully judge. Fix the permissions or reinstall.

From the installed seal command at startup

Section titled “From the installed seal command at startup”

Before running anything, the installed launcher checks the whole store against its install record. The artifact_* tokens above are its refusals too. Minted in scripts/seal-launch.cjs.

The installed launcher’s bytes differ from its entry in lib/seal/install.json. Restore a complete pinned installation; do not change the recorded digest to match the changed file.

The installed launcher cannot read its own file. Check its file permissions or restore a complete pinned installation.

lib/seal/install.json is gone from the install prefix. The launcher refuses to guess what should be in the store. Reinstall.

The install record exists but is not readable JSON (exercised by corrupting it). Reinstall.

The install record’s store path points outside the install prefix. This should never occur from a genuine install; treat the installation as untrustworthy and reinstall from a verified artifact.

The canonical meaning of the four v2 verbs is in Receipt operations. The checker was landed before the producer and does not import the producer’s assembler or canonicaliser. See the normative v2 contract for the full refusal boundary.

The received bytes are not an unambiguous UTF-8 JSON document. Truncation, ill-formed UTF-8, and duplicate names at any depth refuse here.

The envelope does not use the fixed v2 top-level order, or a number is not a finite number in the supported range. Object members inside values use ECMAScript own-property enumeration order after parsing: integer-index keys come first in ascending numeric order, followed by other string keys in insertion order.

The arguments or kernel configuration no longer hash to the recorded replay commitment. Keep the receipt as tamper evidence and do not rely on it.

The granted capabilities do not exactly match the approval targets, or a reserved input channel was populated even though the current kernel does not consume it.

The signature is malformed or does not verify under the caller-supplied key. A receipt-embedded key is never trusted as authority.

Replaying the exact recorded inputs through the WASM kernel produced a different verdict. The receipt does not establish the decision it records.

The receipt’s signed action says ALLOW, but replaying its recorded kernel inputs does not produce ALLOW. The checker refuses the receipt rather than reporting a decision the kernel did not make. Preserve the receipt and report the mismatch.

Up: Guide. Next: What is protected right now.