Agent surfaces
Attach Sealr to an MCP server, to Claude Code hooks, or to a wrapped CLI command, and choose which one to enforce on.
Sealr does not hook into the model. It attaches to the three places where an agent's intent turns into an operation: the MCP server it calls, the tool-use hook the agent harness fires, and the command line it shells out to.
Every surface below assumes the recorder is initialized on the machine. Run sealr init
(offline) or sealr enroll (connected) first — without it, each command exits with
recorder is not initialized. See the quickstart.
MCP stdio proxy
Configure sealr in place of the MCP server. It spawns the real server as a child
process and relays stdio between the agent and the server.
{
"mcpServers": {
"prod-db": {
"command": "sealr",
"args": [
"proxy", "mcp",
"--enforce",
"--name", "prod-db",
"--resource", "db:prod-main",
"--",
"npx", "-y", "@modelcontextprotocol/server-postgres",
"postgresql://[email protected]:5432/app"
]
}
}
}Everything after -- is the target server, launched exactly as you would have launched
it yourself. The database URL stays in the MCP config; Sealr never opens a connection of
its own.
For an ALLOW verdict the wrapped server's behaviour is byte-identical. The request
line is forwarded unchanged and the response is relayed back unchanged. Only
tools/call requests are inspected at all — initialize, tools/list, notifications
and every other method pass through untouched.
For a BLOCK verdict in enforce mode, the request is never forwarded. Sealr answers the agent with a protocol-correct JSON-RPC error carrying the same request id:
{
"jsonrpc": "2.0",
"id": 7,
"error": {
"code": -32000,
"message": "Blocked by Sealr: SQL_UNBOUNDED_WRITE",
"data": { "sealr": true, "verdict": "BLOCK" }
}
}What the guard reads: if the tool arguments contain a query, sql, statement,
command or cmd string, that string is the operation and goes to the matching guard.
Otherwise the whole arguments object is serialized as JSON and the integration is
inferred from the tool name. So postgres_query({sql: "DELETE FROM users"}) is parsed as
PostgreSQL; a tool whose arguments carry SQL under some other key is recorded but
evaluated by the generic guard, which allows everything.
--resource is what binds the traffic to a policy. Without it, records land on
mcp:<name> — usable, but your policy bindings have to name that instead of
db:prod-main. Set SEALR_PRINCIPAL in the agent's environment to attribute the session
to a human.
Claude Code hooks
Hooks cover the agent's own tools — Bash, Edit, Write — which never pass through an
MCP server.
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [{ "type": "command", "command": "sealr hook pre-tool-use --enforce" }]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [{ "type": "command", "command": "sealr hook post-tool-use" }]
}
]
}
}sealr hook pre-tool-use reads the hook JSON on stdin and prints a decision on stdout:
{ "decision": "deny", "reason": "Blocked by Sealr: SQL_UNBOUNDED_WRITE" }The reason string renders in the agent UI, so the agent sees the reason code and can choose a different approach instead of retrying blindly. The hook process itself always exits 0 — the decision is the JSON, not the exit code.
Drop --enforce and the hook records every tool call and returns allow every time.
That is the observe-only configuration.
Two limits worth knowing before you rely on this surface:
- Operations are bound to
tool:<tool_name>(for exampletool:Bash) unless the hook payload carries an explicitresourcefield, so write your bindings against that. - Classification is by tool name and payload shape. A
Bashcall runninggit push --forceis recorded, but it reaches the generic guard, not the git guard, because neither the tool name nor the payload identifies it. To get real classification on shell commands, have the agent call them throughsealr run(below).
sealr hook post-tool-use currently accepts the payload and exits 0 without writing an
outcome record. It is wired up so the configuration does not change later; today the
evidence from this surface is the PreToolUse record alone.
CLI wrapper
sealr run records any command and guards the ones it recognizes.
sealr run --resource db:prod-main -- \
psql -h db.internal -U agent -c "DELETE FROM sessions WHERE last_seen < now() - interval '90 days'"
sealr run --enforce --resource cloud:aws-prod -- terraform apply tfplan
sealr run --enforce --resource repo:acme/api -- git push --force origin mainRecognition is argv-based and deliberately narrow:
| Command | What the guard sees |
|---|---|
psql -c / --command / --command= | the SQL text, parsed with the PostgreSQL grammar |
terraform / tofu | the full argv, not the plan |
git with a push argument | the full argv |
| anything else | nothing — recorded, not evaluated |
The Terraform guard analyzes terraform show -json plan output, but sealr run passes
it argv, not a plan. So a wrapped terraform apply yields TF_HCL_UNANALYZED (WARN)
unless the argv contains a state-manipulation subcommand — state rm, state mv,
import, taint, untaint, force-unlock, apply -replace — which produces
TF_STATE_COMMAND and REQUIRE_APPROVAL. To evaluate a real plan today, feed the JSON
to sealr policy test policy.yaml --integration terraform --input "$(cat plan.json)" --resource cloud:aws-prod. That is a dry-run against a policy file on disk, it records
nothing, and it always exits 0 — so it cannot gate a job by exit code on its own.
Unrecognized commands are not ignored — they become a generic_exec record holding the
redacted argv, the exit code, the wall duration, and BLAKE3 digests of stdout and stderr.
Never the output content, and never the raw command line. You get proof that the
command ran and what it returned, without the recorder becoming a place your secrets
accumulate.
sealr run passes the wrapped command's exit code through unchanged, with two
exceptions: 20 when the operation was blocked (or an approval was denied or
unavailable), and 127 when the command could not be spawned at all.
Choosing a surface
| Sees | Can stop it | Runs where | |
|---|---|---|---|
| MCP proxy | every tools/call to that server | yes, before the server sees it | the agent's machine, per MCP server |
| Claude Code hooks | every tool call the agent makes | yes, the agent gets a deny | the agent's machine, per project |
| CLI wrapper | one command | yes, before exec | anywhere, including CI |
They compose, and you generally want more than one. The proxy is the highest-fidelity surface — it parses real SQL — but it only covers agents that use MCP. Hooks cover the agent's own tools but classify weakly. The wrapper covers scripts, humans and CI, which is where the credentials that actually matter usually live.
An agent that reaches a database by some path you did not wrap produces no records at all. Coverage is a deployment property, not something the recorder can assert for you.
Observe before you enforce
Every surface takes --enforce. Without it, nothing is ever stopped: the operation runs,
the verdict is computed and recorded in observe mode, and you can read what would have
happened.
Deploy every surface without --enforce. Let it run for a week of real agent work.
Read the recorded verdicts. Find the operations that carry a BLOCK decision in observe mode — those are exactly what enforcement would have stopped, including the false positives you have not thought of yet.
Turn on --enforce where the blast radius is smallest and the credentials are biggest,
which is usually CI rather than a developer's laptop.
Two behaviours to know before you flip the switch:
- A resource with no matching policy binding is evaluated in observe mode by default, so adding a policy never silently starts blocking traffic you did not name.
- In enforce mode,
sealr runholds a REQUIRE_APPROVAL operation while a human decides, but the wait is bounded at 120 seconds. If nobody decides in time, or the Console is unreachable, the binding'son_approval_unavailableapplies — the default is to block, giving exit code 20.