Keep an agent safe
Limit what an agent can change through relate with read-only opens, safe mode, backups before writes, retry rules, the LOAD CSV directory, and network egress.
climcpagents
Before you start: Install relate
This guide shows you how to limit what an agent can do to your data through relate: open stores read-only, turn on safe mode An opt-in policy (--safe) that refuses a new named node without a label, an unbounded MATCH read and a remote --load, before anything runs. Read more in the glossary, back up before a write, check destructive statements, retry only what is retryable, and keep file and network access narrow. Each control is a flag or an environment variable on the command the agent runs, and the permission matrix shows which ones the CLI and the MCP The Model Context Protocol, which agent hosts use to call tools; relate serve --mcp exposes a store as read_cypher, get_schema and write_cypher. Read more in the glossary server honor.
Prerequisites
- relate on
PATH(Install relate). - A store to protect. The steps build a small one,
team.rl.
Create the example store
This writes a new single-file store A relate database: a directory, a single .rl file, or :memory:, opened with --database. Read more in the glossary named team.rl that holds three people and two projects. Every later command opens it with --read-only unless the step says it writes:
cat > team.cypher <<'EOF'
CREATE (ada:Person {name: 'Ada', role: 'engineer'}),
(grace:Person {name: 'Grace', role: 'engineer'}),
(noor:Person {name: 'Noor', role: 'maintainer'}),
(relate:Project {name: 'relate', language: 'Rust'}),
(tck:Project {name: 'opencypher-tck', language: 'Gherkin'}),
(ada)-[:WORKS_ON {since: 2024}]->(relate),
(grace)-[:WORKS_ON {since: 2023}]->(relate),
(grace)-[:WORKS_ON {since: 2022}]->(tck),
(noor)-[:MAINTAINS]->(relate),
(ada)-[:KNOWS]->(grace);
EOFrelate query --database ./team.rl --mode envelope < team.cypher{"columnTypes":[],"columns":[],"notifications":[],"planCache":"miss","rows":[],"timings":{"elapsed_ms":1.927083,"execute_ms":1.609875,"lex_parse_ms":0.015000000000000001,"lower_ms":0.020458999999999998,"parse_ms":0.298333,"plan_ms":0.012416,"result_generation_ms":0.00025,"rewrite_ms":0.000542},"truncated":false}Open the store read-only
--read-only opens a store read-only An open mode (--read-only) that never writes, checkpoints or migrates the store; writes fail, and a directory store can be read beside its writer. Read more in the glossary: relate never writes it, never runs a checkpoint The step that writes a fresh snapshot of committed state and recycles old WAL segments: every 300 seconds by default, on clean exit, and on .checkpoint. Read more in the glossary on it, and never migrates it. A write fails before anything changes, with exit code 6:
relate --database ./team.rl --read-only --mode envelope "CREATE (:Person {name: 'Eve'})"The first line is stderr, the second is stdout:
semantic error: WriteOnReadOnlyAccessDatabase: No write operations are allowed on this database. The database is in read-only mode on this Neo4j instance.
{"error":{"code":"validation_error","exit_code":6,"kind":"semantic","message":"semantic error: WriteOnReadOnlyAccessDatabase: No write operations are allowed on this database. The database is in read-only mode on this Neo4j instance.","retryable":false}}| Store | With --read-only |
|---|---|
| Directory | Opens beside a running writer; --read-only=live follows the writer's commits |
.rl file | Opens only while no writer holds it |
:memory: | Rejects writes and creates nothing |
The MCP server takes the same flag. Opened read-only, it lists read_cypher and get_schema and leaves out write_cypher:
relate serve --mcp stdio --database ./team.rl --read-onlyTo allow writes, remove --read-only. read_cypher refuses writes and schema changes either way, with Only MATCH queries are allowed for read-query.
A client handle takes the same guard: Use relate with the Vercel AI SDK opens the store with { readOnly: true } behind an agent tool and plans each statement with EXPLAIN before it runs.
Turn on safe mode
--safe refuses statement shapes that agents commonly get wrong, before anything runs, as SafeModeViolation with exit code 6. It is off unless asked for: --safe or RELATE_SAFE_MODE=1 turns it on, and --no-safe or RELATE_SAFE_MODE=0 turns it off. relate serve --safe applies it to every statement the server runs.
| Safe mode refuses | It allows | Fix |
|---|---|---|
CREATE or MERGE of a new named node without a label | Anonymous () endpoints of a relationship, and nodes bound earlier in the statement | Give the node a label: CREATE (n:Person) |
A MATCH read whose final RETURN or WITH has no LIMIT and no reducing aggregate; collect() does not count, because it keeps every row | A RETURN of constants only, SHOW and CALL-only statements, and MATCH … SET or MATCH … DELETE writes | Add LIMIT <n>, or return only reducing aggregates, such as count(*), with no grouping key |
A remote --load source, with exit code 2, before any network request | A local --load source, and --fetch-params | Download the file, check its SHA-256, and load the local copy |
A new named node without a label, on an in-memory store that nothing keeps:
relate --safe --mode envelope "CREATE (n {name: 'Eve'}) RETURN n.name AS name"syntax error: SafeModeViolation: This statement is not allowed in safe mode: CREATE or MERGE of a new node `n` without a label. Safe mode refuses shapes an agent commonly gets wrong — give the node a label, e.g. `CREATE (n:Person)`, so the write is self-describing. This is a policy, not a database limit; reopen without --safe to allow it.
{"error":{"code":"validation_error","exit_code":6,"kind":"syntax","message":"syntax error: SafeModeViolation: This statement is not allowed in safe mode: CREATE or MERGE of a new node `n` without a label. Safe mode refuses shapes an agent commonly gets wrong — give the node a label, e.g. `CREATE (n:Person)`, so the write is self-describing. This is a policy, not a database limit; reopen without --safe to allow it.","retryable":false}}A collect() with no LIMIT:
relate --database ./team.rl --read-only --safe --mode envelope 'MATCH (p:Person) RETURN collect(p.name) AS names'syntax error: SafeModeViolation: This statement is not allowed in safe mode: an unbounded read (`MATCH … RETURN` or `MATCH … WITH` without `LIMIT`). Safe mode refuses shapes an agent commonly gets wrong — add `LIMIT <n>` (collect() buffers every row, so it does not count as a bound even beside count() or another reducing aggregate; use count() for a bounded total). This is a policy, not a database limit; reopen without --safe to allow it.
{"error":{"code":"validation_error","exit_code":6,"kind":"syntax","message":"syntax error: SafeModeViolation: This statement is not allowed in safe mode: an unbounded read (`MATCH … RETURN` or `MATCH … WITH` without `LIMIT`). Safe mode refuses shapes an agent commonly gets wrong — add `LIMIT <n>` (collect() buffers every row, so it does not count as a bound even beside count() or another reducing aggregate; use count() for a bounded total). This is a policy, not a database limit; reopen without --safe to allow it.","retryable":false}}Safe mode judges the shape of a statement, not its effect. It cannot see the store's size, so it refuses an unbounded read of a small store too, and a labeled MATCH (p:Person) DETACH DELETE p passes it. A grouping aggregate such as RETURN p.name, count(*) is still unbounded and is refused. A CALL { … } subquery is checked on its own, so a MATCH … RETURN inside one needs its own LIMIT. For an agent that only reads, --read-only is the fence and --safe keeps its reads bounded.
Back up before a write
relate clone copies a store to a new path and exits. It opens the source read-only, and the destination must not exist yet. A clone before an agent writes turns a mistake into a file copy:
relate clone ./team.rl ./team-backup.rlrelate clone prints nothing when it succeeds. The copy holds all five nodes:
relate --database ./team-backup.rl --read-only --mode envelope 'MATCH (n) RETURN count(n) AS nodes'{"columnTypes":["INTEGER"],"columns":["nodes"],"notifications":[],"planCache":"miss","rows":[{"nodes":5}],"timings":{"elapsed_ms":0.5135,"execute_ms":0.351833,"lex_parse_ms":0.027875,"lower_ms":0.042624999999999996,"parse_ms":0.10258300000000001,"plan_ms":0.049833999999999996,"result_generation_ms":0.00033299999999999996,"rewrite_ms":0.008084},"truncated":false}A directory store can be cloned while its writer runs. A .rl source fails with Failed to clone database: storage locked: <path> (exit code 1) while a writer holds it. To rehearse a write instead of backing up, --load ./team.rl --database ./rehearsal.rl copies the store and opens the copy for writing, as the agent quickstart shows.
Inside one session, .begin and .rollback undo a destructive statement after its effect has been checked. This script opens team.rl for writing, deletes a project, counts, and rolls the delete back:
relate query --database ./team.rl --mode envelope <<'EOF'
.begin
MATCH (p:Project {name: 'opencypher-tck'}) DETACH DELETE p;
MATCH (p:Project) RETURN count(p) AS projects;
.rollback
MATCH (p:Project) RETURN count(p) AS projects;
EOF{"columnTypes":[],"columns":[],"notifications":[],"planCache":"miss","rows":[],"timings":{"elapsed_ms":0.5173340000000001,"execute_ms":0.332333,"lex_parse_ms":0.003167,"lower_ms":0.005,"parse_ms":0.175583,"plan_ms":0.004583,"result_generation_ms":0.000166,"rewrite_ms":0.00033299999999999996},"truncated":false}
{"columnTypes":["INTEGER"],"columns":["projects"],"notifications":[],"planCache":"miss","rows":[{"projects":1}],"timings":{"elapsed_ms":0.08254099999999999,"execute_ms":0.038167,"lex_parse_ms":0.007875,"lower_ms":0.01225,"parse_ms":0.026250000000000002,"plan_ms":0.01475,"result_generation_ms":0.000209,"rewrite_ms":0.0007080000000000001},"truncated":false}
{"columnTypes":["INTEGER"],"columns":["projects"],"notifications":[],"planCache":"hit-raw-fastpath","rows":[{"projects":2}],"timings":{"elapsed_ms":0.028625,"execute_ms":0.006292,"lex_parse_ms":0.0,"lower_ms":0.0,"parse_ms":0.00020800000000000001,"plan_ms":0.021542000000000002,"result_generation_ms":0.000084,"rewrite_ms":0.0},"truncated":false}Inside the transaction one project is left; after the rollback, both are back.
Check destructive statements before they run
The CLI prints no summary of what a write changed, so a read afterwards is the check. Two cases need care:
DROP INDEXandDROP CONSTRAINTprint nothing when they succeed.SHOW INDEXESandSHOW CONSTRAINTSlist what exists before anything is dropped.- A write that changed nothing exits 0 and prints a note on stderr. A
MATCHthat finds no rows skips the rest of its statement, so theSETnever runs.
This statement opens team.rl for writing but matches nobody:
relate --database ./team.rl --mode envelope "MATCH (p:Person {name: 'Zed'}) SET p.role = 'maintainer'"{"columnTypes":[],"columns":[],"notifications":[],"planCache":"miss","rows":[],"timings":{"elapsed_ms":0.397583,"execute_ms":0.208541,"lex_parse_ms":0.005915999999999999,"lower_ms":0.005417,"parse_ms":0.17962499999999998,"plan_ms":0.004667,"result_generation_ms":0.000166,"rewrite_ms":0.00033299999999999996},"truncated":false}
note: write statement changed nothing; the graph is unchangedThe prefix to match on stderr is note: write statement; a transaction that persisted nothing prints note: .commit changed nothing; the transaction applied no changes.
Retry only retryable errors
The error object's retryable is true for a write-write conflict (exit 5) and for a --concurrent-logical-workspace-limit refusal while other statements hold the shared pool (exit 1, kind resource; reachable when statements run concurrently, as under relate serve). A one-shot CLI run meets only the first. Every other error repeats when the same statement runs again:
| Error | Exit code | retryable | Remedy |
|---|---|---|---|
Write-write conflict, kind conflict | 5 | true | Run the statement again in a fresh transaction |
resource limit exceeded: concurrent-logical-workspace-limit limit=<bytes>, kind resource, while other statements hold the pool | 1 | true | Run it again once other statements finish, or raise --concurrent-logical-workspace-limit |
storage locked: <path>, kind locked | 5 | false | Wait for the other process to exit; a directory store can also be opened --read-only beside its writer, a .rl store cannot |
Error: query timed out after <duration> | 7 | false | Bound the statement with LIMIT or a narrower pattern, or raise --timeout |
| Any rejected statement: syntax, parameter, type, safe mode, read-only | 6 | false | Fix the statement |
A runtime failure: missing store, I/O, capacity, LOAD CSV | 1 | false | Read message and fix the cause |
Drive relate from an agent shows the whole error object.
Keep LOAD CSV inside one directory
LOAD CSV reads no local file by default. --load-csv-dir <dir>, or RELATE_LOAD_CSV_DIR when the flag is absent, allows regular files under that one directory, up to 8 MiB (8,388,608 bytes) each. A path outside it, a symlink that escapes it, a special file, and a URL all fail the same way: the exit code is 1, and the message names no path. The first command writes a CSV file under imports/:
mkdir -p imports
printf 'name,role\nOla,engineer\n' > imports/people.csvrelate --mode envelope "LOAD CSV WITH HEADERS FROM 'people.csv' AS row RETURN row.name AS name"LOAD CSV file unavailable
{"error":{"code":"fatal_error","exit_code":1,"kind":"execution","message":"LOAD CSV file unavailable","retryable":false}}With the directory named, the same statement reads the file:
relate --load-csv-dir ./imports --mode envelope "LOAD CSV WITH HEADERS FROM 'people.csv' AS row RETURN row.name AS name"{"columnTypes":["STRING"],"columns":["name"],"notifications":[],"planCache":"miss","rows":[{"name":"Ola"}],"timings":{"elapsed_ms":0.445625,"execute_ms":0.269958,"lex_parse_ms":0.0050420000000000005,"lower_ms":0.003791,"parse_ms":0.16458299999999998,"plan_ms":0.006,"result_generation_ms":0.00033299999999999996,"rewrite_ms":0.000417},"truncated":false}relate serve has no --load-csv-dir flag: RELATE_LOAD_CSV_DIR in the server's environment is its only switch. Over MCP, read_cypher refuses LOAD CSV, and write_cypher runs it when the directory is set and the store is writable.
Control network egress
These commands and flags reach the network, and each one is visible on the command line an agent runs:
| Command or flag | Reaches | Limits | Checks |
|---|---|---|---|
--load https://…/<name>.rl | Any https:// host; plain http:// only for a loopback host | 1 GiB and 300 seconds; the downloaded copy is deleted after the load | No signature and no host allowlist; --safe refuses it |
--fetch-params <name>=<url> | Any http:// or https:// URL | 64 MiB and 60 seconds | No allowlist; --safe does not refuse it |
relate upgrade | releases.json on relatedb.ai, then the release's files on GitHub | — | The checksum list's signature against the key pinned in the binary, then the binary's checksum; any failure stops it before the binary changes |
relate extension install | The signed first-party channel, a GitHub release, or the --url given | — | Per trust tier; see the permission matrix |
relate pull, relate push | The Neo4j or Aura server named on the command line | — | — |
| Usage metrics | Mixpanel, with anonymous usage events | — | DO_NOT_TRACK=1 turns them off (Privacy and telemetry) |
For a store published at a URL, the checked path is to download it, compare its SHA-256 with the published value, and load the local copy. A --load into the default in-memory store is read-only. In this block <sha256> is the published value, and relate runs only when it matches; --strict also fails a malformed checksum line. Where sha256sum is missing, shasum -a 256 --strict -c - stands in for it:
curl -sSfL -o graph.rl https://<host>/<path>/graph.rl
echo "<sha256> graph.rl" | sha256sum --strict -c - && relate --load ./graph.rl --mode envelope 'MATCH (n) RETURN count(n) AS nodes'Permission matrix
What each control does on each surface. MCP stdio is relate serve --mcp stdio, and MCP HTTP is relate serve --mcp <port>. "n/a" means the control does not exist on that surface.
| Control | CLI | MCP stdio | MCP HTTP |
|---|---|---|---|
--read-only | Off by default. Writes fail with exit code 6; a directory store opens beside its writer | Off by default. With it, write_cypher is not listed; read_cypher refuses writes and schema changes in every mode | Same as MCP stdio |
--safe | Off by default (RELATE_SAFE_MODE). Refuses a new named node without a label, an unbounded MATCH read, and a remote --load | relate serve --safe: the same statement refusals on every tool call, and a remote --load refused at startup | Same as MCP stdio |
LOAD CSV policy | Denied by default. --load-csv-dir <dir> or RELATE_LOAD_CSV_DIR allows regular files under one directory, 8 MiB each; no URLs | No flag; RELATE_LOAD_CSV_DIR in the server's environment only. read_cypher refuses LOAD CSV; write_cypher runs it | Same as MCP stdio |
--fetch-params and --load egress | --fetch-params: http or https URL or file, 64 MiB, 60 seconds, no allowlist. --load: https (or loopback http) .rl URL, 1 GiB, 300 seconds | --fetch-params is not a serve flag; parameters arrive in the tool call. relate serve --load fetches once, at startup, under the CLI's rules | Same as MCP stdio |
| Extension tiers | relate extension install: first-party (signed channel, checked against the release key), GitHub pin (--github: tag, commit and asset SHA-256 recorded; a moved tag is refused), third party (--url: https, or http to a loopback host), local (--from <dir>). relate run runs installed packages only; an uninstalled name fails without fetching anything | n/a: the server's tools are read_cypher, write_cypher, and get_schema | n/a |
upgrade | relate upgrade installs the latest release after its signature and checksum pass, on macOS and Linux; --check changes nothing. It reinstalls the skill for every detected agent without asking once any of them holds a relate copy; with none, it asks first (--yes skips the question) | n/a | n/a |
MCP over HTTP also asks for HTTP Basic authentication by default: relate serve generates a token, prints it at startup, and keeps it in a <store>.auth-token file beside the store. MCP over stdio has no authentication, because the host that starts the server is its only client.
Verify
With --read-only and --safe together, a destructive statement from an agent fails and the store stays as it was:
relate --database ./team.rl --read-only --safe --mode envelope 'MATCH (p:Person) DETACH DELETE p'
echo "exit=$?"semantic error: WriteOnReadOnlyAccessDatabase: No write operations are allowed on this database. The database is in read-only mode on this Neo4j instance.
{"error":{"code":"validation_error","exit_code":6,"kind":"semantic","message":"semantic error: WriteOnReadOnlyAccessDatabase: No write operations are allowed on this database. The database is in read-only mode on this Neo4j instance.","retryable":false}}
exit=6All three people are still there, and a bounded count passes safe mode:
relate --database ./team.rl --read-only --safe --mode envelope 'MATCH (p:Person) RETURN count(p) AS people'{"columnTypes":["INTEGER"],"columns":["people"],"notifications":[],"planCache":"miss","rows":[{"people":3}],"timings":{"elapsed_ms":0.334542,"execute_ms":0.16141699999999998,"lex_parse_ms":0.030000000000000002,"lower_ms":0.04375,"parse_ms":0.108167,"plan_ms":0.055958,"result_generation_ms":0.00033299999999999996,"rewrite_ms":0.008792},"truncated":false}If it goes wrong
semantic error: WriteOnReadOnlyAccessDatabase: …(exit code 6): a write reached a read-only open. When the write is intended, back up the store and run it in a separate command without--read-only(errors reference).syntax error: SafeModeViolation: This statement is not allowed in safe mode: …(exit code 6): the statement has one of the refused shapes. Add the label, theLIMIT, or a reducing aggregate; safe mode is a policy, so reopening without--safealso allows it (errors reference).LOAD CSV file unavailable(exit code 1): no--load-csv-dirwas given, or the file is outside it, a symlink escape, or a special file. Put the file under the directory and pass--load-csv-dir <dir>(errors reference).Error: --safe refuses a remote --load source — …(exit code 2): safe mode never loads a store from the network. Download the file, check its SHA-256, and pass the local path to--load.
Related
- Drive relate from an agent: the envelope, the error object, and the exit codes.
- Connect over MCP: read-only config blocks for each agent host.
- Back up and restore a store:
relate cloneand checkpoints in depth. - CLI flags:
--read-only,--safe,--load-csv-dir, and their environment variables. - Errors: every message with its cause, remedy, and retry advice.
- Security: the release signing key and how downloads are verified.
- Run relate in a sandbox: what to allow when the agent runs inside one.
- Transactions and concurrency: why only a write-write conflict is worth a retry.
- Limitations: what relate does not do, including what safe mode does not catch.
- Agent-native by design: why the CLI's defaults look the way they do for agents.