Skip to main content
Sheetbase Docs
Concepts

Safety Systems

Every guard that runs before a write. Fail-closed by default. Source-verified from route.ts.

Sheetbase runs two sequential safety layers before every write — a formula check and a schema guard. The schema guard also performs note guard preflight. These checks are fail-closed: they block the write and return a structured error unless explicitly overridden. SQL tools have a separate, non-bypassable Postgres sandbox.


Layer 1 — Formula overwrite guard (assertNoFormulaOverwrite)

Runs on every write_range and transform_range call. Steps in order:

  1. If allowFormulaOverwrite: true → skip this guard entirely
  2. Fetch the target range with valueRenderOption: FORMULA
  3. Scan every cell for values starting with =
  4. If any formula cells found → block the write with a detailed error listing every affected cell address, the existing formula, the proposed value, and a small diff preview
write_range blocked because the target range contains formula cells (3):
Sheet1!B2, Sheet1!B3, Sheet1!B4
Formula overwrite preview: Sheet1!B2 existing formula "=UPPER(A2)"
-> proposed formula "=LOWER(A2)" (replace "UPP" with "LOW")
Pass allowFormulaOverwrite: true only after confirming the existing formula
vs proposed value diff is intentional.

This is distinct from the ARRAYFORMULA spill guard (see Layer 2). This guard catches any formula — =SUM(), =VLOOKUP(), =IF(), etc.


Layer 2 — Schema guard (runPreWriteSchemaGuards)

Runs on every write tool after Layer 1. Uses the schema cache (__schema__:<sheet> key in sheet_snapshots, TTL = 300 seconds) to avoid repeated structure API calls.

The schema is built from two cheap API calls:

  • spreadsheets.get — fetches frozen rows, protected ranges, conditional format count, merges, banded ranges
  • spreadsheets.values.get on rows 1-25, valueRenderOption: FORMULA — detects top-row/header-area ARRAYFORMULA anchors

Protected Range check

Compares the write footprint (computed from the actual values[][] array dimensions) against every protectedRange in the schema.

Protection typeBehaviour
warningOnly: false (strict lock)Write blocked. Returns "Write blocked — range overlaps a Protected Range (locked). This cannot be overridden."
warningOnly: true (soft warning)Write proceeds. _warnings array added to response: "⚠ Target range has a user-set protection warning."

ARRAYFORMULA column guard

Checks if any column in the write footprint matches an ARRAYFORMULA anchor column (detected from rows 1-25 in the schema cache). If a match is found and allowFormulaOverwrite is false:

Write blocked — target range Data!D2:N100 overlaps ARRAYFORMULA-owned
column(s): E (anchor Data!E1), F (anchor Data!F1), G (anchor Data!G1),
J (anchor Data!J1). Guard reason: writing static values into generated
column(s) E, F, G, J can replace spill output and break downstream formulas.
Safest minimal write: split or narrow the write range to exclude column(s)
E, F, G, J. Pass allowFormulaOverwrite: true only after confirming this
formula/spill overwrite is intentional.

Warning: The ARRAYFORMULA guard checks column overlap only, not cell overlap. If any cell in the target range shares a column with an ARRAYFORMULA anchor in the schema scan, the write is blocked. This is intentionally conservative.

Note guard preflight

Before committing values, write tools fetch notes for the target columns' header row and, for small ranges, exact target-cell notes. Structured note prefixes are interpreted as metadata:

PrefixBehaviour
[INFO]Read-visible metadata only; does not block
[WARN]Blocks write unless allowNoteGuardOverride: true
[FORMULA]Blocks write unless allowNoteGuardOverride: true
[LOCK], [DO_NOT_WRITE], [NO_WRITE]Blocks write unless allowNoteGuardOverride: true

Unstructured notes stay visible in _schema.columns when read_range includes header notes, but they are not treated as write guards.

What _schema looks like (returned by read_range)

read_range always returns the schema alongside values so agents can reason about the sheet structure before writing:

{
  "values": [["Name","Revenue","Status"], ["Acme","12000","Active"]],
  "_schema": {
    "layout": { "frozenRows": 1, "frozenColumns": 0 },
    "semantics": ["Has 2 conditional format rules"],
    "constraints": [],
    "protectedRanges": [
      { "range": { "startRowIndex": 0, "endRowIndex": 1 }, "warningOnly": false, "description": "Header row" }
    ],
    "arrayFormulaColumns": ["C"],
    "arrayFormulaAnchors": [
      {
        "columnIndex": 2,
        "columnName": "C",
        "formulaCell": {
          "address": "Sheet1!C1",
          "rowIndex": 0,
          "columnIndex": 2,
          "formula": "=ARRAYFORMULA(...)"
        }
      }
    ],
    "warnings": ["⚠ Column C contains ARRAYFORMULA at Sheet1!C1 — do not write static values into C:C"],
    "mergedTitleRows": []
  }
}

Agents should check _schema.arrayFormulaColumns, _schema.protectedRanges, and _schema.columns[].noteGuard before calling any write tool.


Layer 3 — SQL sandbox (analyze_range + transform_range)

These tools execute user-supplied SQL inside a hardened Postgres transaction. Three enforcement levels:

Regex guard (application layer)

Applied before the query reaches Postgres:

Pattern stripped/blockedWhy
; stripped from queryPrevents statement chaining (SELECT 1; DROP TABLE users)
DROP in query → rejectedPrevents schema destruction
DELETE in query → rejectedPrevents data deletion via Data-Modifying CTEs
UNION strippedPrevents data exfiltration via UNION-based injection

Note

The strip is designed not to break column names that contain blocked words. E.g. data ->>'Dropoff Time' is safe — the UNION/DELETE stripping targets full keywords, not substrings inside string literals.

Transaction sandbox (Postgres layer)

Every SQL execution uses a dedicated client from the pool and runs inside:

BEGIN READ ONLY;
SET LOCAL ROLE sheet_analyzer;
-- user query runs here
ROLLBACK;

sheet_analyzer is a restricted Postgres role with:

  • No login privileges
  • No access to account, user, session, apikey, sheet_snapshots, or write_history tables
  • GRANT USAGE ON SCHEMA public only — enough to use Postgres built-in functions and types

Even if the regex guard is bypassed and a Data-Modifying CTE is constructed, Postgres enforces READ ONLY at the transaction level and rejects it.

Context safety clamp

LIMIT 1000 is automatically appended to every analyze_range query before execution. This prevents runaway result sets from consuming the agent's context window.

transform_range has a pre-execution cell count guard: if rows × columns > 50,000, the entire call is rejected before any SQL runs.


Response size clamps (read_range)

read_range applies output clamps after fetching data:

ConstantValueBehaviour when exceeded
MAX_RESPONSE_ROWS1,000Result truncated to 1,000 rows
MAX_RESPONSE_BYTES2 MBResult truncated to 1,000 rows

When truncation occurs, a second content item is added to the MCP response:

⚠ read_range: Truncated to 1000 rows (original: 14823).
Use search_rows for exact row lookup, analyze_range with SQL for aggregates/filtering, or a narrower A1 range for bounded samples.

AX implication: If an agent receives a truncation warning from read_range, it must switch tools instead of retrying the same large range. Use search_rows for exact row lookup, analyze_range with SQL for aggregates/filtering, or a narrower A1 range for bounded samples.


Schema cache invalidation

Schema cache entries (__schema__:<sheet>, TTL = 300s) and all range caches are fully cleared from sheet_snapshots after structural writes. Data-only writes may preserve schema entries while clearing range data caches. This ensures value reads see fresh data while avoiding repeated structure fetches.

On this page