@synapsor/runner 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +35 -33
- package/SECURITY.md +11 -0
- package/dist/runner.mjs +109 -52
- package/docs/README.md +4 -0
- package/docs/benchmarks/bounded-set-local.md +27 -0
- package/docs/bounded-set-writeback.md +298 -0
- package/docs/capability-authoring.md +6 -0
- package/docs/release-notes.md +23 -0
- package/docs/result-envelope-v3.md +43 -0
- package/docs/security-boundary.md +7 -0
- package/docs/why-synapsor-vs-app-guardrails.md +169 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.1 (prepared, not published)
|
|
4
|
+
|
|
5
|
+
### Canonical Bounded-Set Digest Verification
|
|
6
|
+
|
|
7
|
+
- Fixes contract-authored bounded-set proposals that could fail unchanged
|
|
8
|
+
apply with `SET_DIGEST_MISMATCH` after protocol parsing reordered aggregate
|
|
9
|
+
object fields.
|
|
10
|
+
- Uses recursive canonical JSON key ordering for new member and set digests,
|
|
11
|
+
while narrowly accepting the deterministic raw representations emitted by
|
|
12
|
+
`1.4.0` so valid stored proposals remain applyable.
|
|
13
|
+
- Keeps every frozen member, expected version, reviewed value, aggregate,
|
|
14
|
+
tenant guard, and atomic source check intact; malformed digests and genuine
|
|
15
|
+
source drift still fail closed.
|
|
16
|
+
- Adds PostgreSQL/MySQL regression coverage for the exact DSL-to-contract path
|
|
17
|
+
under source-database and Runner-ledger receipt authority, plus independent
|
|
18
|
+
version, predicate, aggregate, writable-value, missing-member, and tenant
|
|
19
|
+
drift checks.
|
|
20
|
+
- Ships the bounded-set guide and other linked public docs in the Runner
|
|
21
|
+
tarball, and fails package assembly when a shipped local Markdown link cannot
|
|
22
|
+
resolve.
|
|
23
|
+
- Removes obsolete `0.1 preview` wording from the DSL package without changing
|
|
24
|
+
canonical Spec `spec_version: "0.1"`.
|
|
25
|
+
- Adds a build-vs-adopt guide for teams already using prompts and custom
|
|
26
|
+
parameterized tools. It distinguishes behavioral instructions from
|
|
27
|
+
structural authorization, explains where SQL authority lives, and documents
|
|
28
|
+
the approval, receipt, replay, and compensation layer without claiming
|
|
29
|
+
prompt-injection prevention or compliance certification.
|
|
30
|
+
- Stages `@synapsor/runner@1.4.1` and `@synapsor/dsl@1.4.1`;
|
|
31
|
+
`@synapsor/spec` remains `1.4.0` because the public contract schema did not
|
|
32
|
+
change. Nothing is published by this change.
|
|
33
|
+
|
|
3
34
|
## 1.4.0 (prepared, not published)
|
|
4
35
|
|
|
5
36
|
### Reviewed Reversible Change Sets
|
package/README.md
CHANGED
|
@@ -88,10 +88,26 @@ approve/reject prompt:
|
|
|
88
88
|
- evidence, query audit, idempotency receipts, and replay preserve what was
|
|
89
89
|
inspected, requested, approved, and applied.
|
|
90
90
|
|
|
91
|
-
Runner supports bounded
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
Runner supports bounded deployments when its documented database, identity,
|
|
92
|
+
ledger, backup, and operational controls are satisfied. It does not make raw
|
|
93
|
+
SQL tools, host infrastructure, or prompt-injection-prone clients safe.
|
|
94
|
+
|
|
95
|
+
## Why Not Just Use A Prompt And App Code?
|
|
96
|
+
|
|
97
|
+
Prompt instructions can guide behavior, but prompt-only enforcement is not an
|
|
98
|
+
authorization boundary. First ask who produces the SQL:
|
|
99
|
+
|
|
100
|
+
- **The model produces SQL:** your validator must safely understand arbitrary
|
|
101
|
+
queries, scope, and side effects. That is `execute_sql` with an extra parser.
|
|
102
|
+
- **Trusted app code produces fixed, parameterized SQL:** good. You have built
|
|
103
|
+
a semantic tool, and that may be enough for a small read-only application.
|
|
104
|
+
|
|
105
|
+
Runner becomes useful when those tools also need one reviewed contract for
|
|
106
|
+
trusted tenant scope, field controls, evidence, approval outside MCP, guarded
|
|
107
|
+
and idempotent writeback, receipts, replay, and reviewed compensation. Use your
|
|
108
|
+
own code when you do not need that lifecycle. Read the [full build-vs-adopt
|
|
109
|
+
guide](docs/why-synapsor-vs-app-guardrails.md) for the decision table, public
|
|
110
|
+
incident evidence, and regulated-data boundaries.
|
|
95
111
|
|
|
96
112
|
## Connect A Staging Database
|
|
97
113
|
|
|
@@ -109,11 +125,9 @@ creates trusted context, generates reviewed capabilities, previews the MCP tool
|
|
|
109
125
|
surface, and prints the next smoke and serve commands. It stores environment
|
|
110
126
|
variable names, not connection strings.
|
|
111
127
|
|
|
112
|
-
The
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
statement timeouts) is generated for you; the full file is in the
|
|
116
|
-
[own-database guide](docs/getting-started-own-database.md).
|
|
128
|
+
The generated capability is a tenant-scoped read with an explicit column
|
|
129
|
+
allowlist and required evidence. See the [own-database
|
|
130
|
+
guide](docs/getting-started-own-database.md) for the full configuration.
|
|
117
131
|
|
|
118
132
|
```json
|
|
119
133
|
{
|
|
@@ -174,19 +188,11 @@ trust boundaries, covered threats, non-goals, and required operator controls.
|
|
|
174
188
|
- MCP proposal, evidence, and replay handles are references rather than bearer
|
|
175
189
|
authority: resource reads re-check the owning tenant and principal against
|
|
176
190
|
the current trusted session.
|
|
177
|
-
- `
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
and DELETE across both databases and all receipt modes, including the
|
|
183
|
-
zero-source-schema Runner-ledger path and fail-closed reconciliation.
|
|
184
|
-
- `corepack pnpm test:bounded-set` proves proposal-time cap and aggregate
|
|
185
|
-
rejection, frozen-set drift checks, all-or-nothing set UPDATE/DELETE and
|
|
186
|
-
batch INSERT, delete-side-effect refusal, exact receipts, and 1/10/100-row
|
|
187
|
-
bounds on disposable PostgreSQL and MySQL.
|
|
188
|
-
- `corepack pnpm test:reversible` proves approval, conflict guards, redaction,
|
|
189
|
-
receipts, and replay on PostgreSQL and MySQL.
|
|
191
|
+
- `test:live-apply`, `test:guarded-crud`, `test:bounded-set`, and
|
|
192
|
+
`test:reversible` run disposable PostgreSQL/MySQL scenarios. They prove no
|
|
193
|
+
pre-approval mutation, guarded single-row CRUD, idempotent retry,
|
|
194
|
+
fail-closed conflicts, bounded atomic sets, exact receipts, and reviewed
|
|
195
|
+
compensation.
|
|
190
196
|
- The C++/Cloud round-trip verifier exports normalized contracts, validates
|
|
191
197
|
them with `@synapsor/spec`, and loads them in Runner. The shared contract and
|
|
192
198
|
verification commands are documented in [Conformance](docs/conformance.md).
|
|
@@ -198,12 +204,9 @@ See [Security Boundary](docs/security-boundary.md) and
|
|
|
198
204
|
|
|
199
205
|
## Operate The Approval Loop
|
|
200
206
|
|
|
201
|
-
Reviewed policies
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
with `apply --all-approved --yes`, inspect Prometheus counters with `metrics
|
|
205
|
-
show`, and consume safe newline-delimited JSON outcome logs from stderr. Signed
|
|
206
|
-
operator keys can enforce contract reviewer roles and separate apply roles.
|
|
207
|
+
Reviewed policies combine per-proposal and daily ceilings; exceeding one routes
|
|
208
|
+
the proposal to human review. Operators use `apply --all-approved --yes`,
|
|
209
|
+
Prometheus metrics, structured logs, and optional signed reviewer/apply roles.
|
|
207
210
|
Shared Postgres ledger mirror mode is available for bounded operator handoffs,
|
|
208
211
|
and `storage.shared_postgres.mode = "runtime_store"` lets MCP serving use
|
|
209
212
|
Postgres as the primary proposal/evidence/replay store with bounded CLI
|
|
@@ -230,11 +233,10 @@ dead-letter, and rolling-upgrade rules are in
|
|
|
230
233
|
| `@synapsor/spec` | Canonical portable contracts for contexts, capabilities, workflows, evidence, proposals, receipts, and replay. |
|
|
231
234
|
| `@synapsor/dsl` | SQL-like authoring that compiles contexts, capabilities, and workflow declarations into canonical contract JSON. |
|
|
232
235
|
|
|
233
|
-
Runner executes locally. The spec is
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
legacy `.synapsor` files remain supported and produce the same contract JSON.
|
|
236
|
+
Runner executes locally. The spec is shared by Runner and Cloud/C++; the DSL
|
|
237
|
+
is its reviewable source format. Start with [Capability
|
|
238
|
+
Authoring](docs/capability-authoring.md). Use `.synapsor.sql` for generic SQL
|
|
239
|
+
highlighting; legacy `.synapsor` files produce the same contract JSON.
|
|
238
240
|
|
|
239
241
|
Use the [DSL Reference](docs/dsl-reference.md) for exact authoring grammar and
|
|
240
242
|
the [Runner Config Reference](docs/runner-config-reference.md) for every wiring
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
Report security issues privately to security@synapsor.ai.
|
|
4
|
+
|
|
5
|
+
Do not include production credentials, customer data, or full source rows in bug reports. Include runner version, job id, proposal id, source id, engine, safe error code, and redacted logs where possible.
|
|
6
|
+
|
|
7
|
+
## Supported security scope
|
|
8
|
+
|
|
9
|
+
The v0.1 runner protects the local writeback boundary for approved structured jobs. It does not make the hosted Synapsor control plane self-hosted, and it does not provide HA, compliance certification, or a general database proxy.
|
|
10
|
+
|
|
11
|
+
See `docs/threat-model.md` for detailed trust boundaries, covered threats, non-goals, and release blockers.
|
package/dist/runner.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// apps/runner/src/cli.ts
|
|
4
4
|
import fs5 from "node:fs/promises";
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
-
import
|
|
6
|
+
import crypto9 from "node:crypto";
|
|
7
7
|
import net from "node:net";
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import path5 from "node:path";
|
|
@@ -12,7 +12,42 @@ import readline from "node:readline/promises";
|
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
13
|
|
|
14
14
|
// packages/protocol/src/index.ts
|
|
15
|
+
import crypto2 from "node:crypto";
|
|
15
16
|
import { z } from "zod";
|
|
17
|
+
function canonicalJsonStringify(input) {
|
|
18
|
+
return JSON.stringify(canonicalJsonValue(input, /* @__PURE__ */ new Set()));
|
|
19
|
+
}
|
|
20
|
+
function canonicalJsonDigest(input) {
|
|
21
|
+
return `sha256:${crypto2.createHash("sha256").update(canonicalJsonStringify(input)).digest("hex")}`;
|
|
22
|
+
}
|
|
23
|
+
function canonicalJsonValue(input, ancestors) {
|
|
24
|
+
if (input === null || typeof input === "string" || typeof input === "boolean") return input;
|
|
25
|
+
if (typeof input === "number") {
|
|
26
|
+
if (!Number.isFinite(input)) throw new TypeError("canonical JSON accepts only finite numbers");
|
|
27
|
+
return Object.is(input, -0) ? 0 : input;
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(input)) {
|
|
30
|
+
if (ancestors.has(input)) throw new TypeError("canonical JSON does not accept circular values");
|
|
31
|
+
ancestors.add(input);
|
|
32
|
+
const output2 = input.map((item) => canonicalJsonValue(item, ancestors));
|
|
33
|
+
ancestors.delete(input);
|
|
34
|
+
return output2;
|
|
35
|
+
}
|
|
36
|
+
if (typeof input !== "object" || input === void 0) throw new TypeError("canonical JSON accepts only JSON values");
|
|
37
|
+
const record = input;
|
|
38
|
+
const prototype = Object.getPrototypeOf(record);
|
|
39
|
+
if (prototype !== Object.prototype && prototype !== null) throw new TypeError("canonical JSON accepts only plain objects");
|
|
40
|
+
if (ancestors.has(record)) throw new TypeError("canonical JSON does not accept circular values");
|
|
41
|
+
ancestors.add(record);
|
|
42
|
+
const output = {};
|
|
43
|
+
for (const key of Object.keys(record).sort()) {
|
|
44
|
+
const value = record[key];
|
|
45
|
+
if (value === void 0) throw new TypeError("canonical JSON does not accept undefined values");
|
|
46
|
+
output[key] = canonicalJsonValue(value, ancestors);
|
|
47
|
+
}
|
|
48
|
+
ancestors.delete(record);
|
|
49
|
+
return output;
|
|
50
|
+
}
|
|
16
51
|
var scalar = z.union([z.string(), z.number(), z.boolean(), z.null()]);
|
|
17
52
|
var scalarMap = z.record(scalar).refine((value) => Object.keys(value).length > 0, "object must not be empty");
|
|
18
53
|
var boundedScalarRecord = z.record(scalar).refine((value) => Object.keys(value).length <= 256, "object exceeds 256 fields");
|
|
@@ -2755,7 +2790,7 @@ function isRecord(value) {
|
|
|
2755
2790
|
}
|
|
2756
2791
|
|
|
2757
2792
|
// packages/mcp-server/src/index.ts
|
|
2758
|
-
import
|
|
2793
|
+
import crypto5 from "node:crypto";
|
|
2759
2794
|
import fs2 from "node:fs";
|
|
2760
2795
|
import { createServer } from "node:http";
|
|
2761
2796
|
import { createServer as createHttpsServer } from "node:https";
|
|
@@ -2766,11 +2801,11 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
2766
2801
|
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
2767
2802
|
|
|
2768
2803
|
// packages/postgres/src/index.ts
|
|
2769
|
-
import
|
|
2804
|
+
import crypto4 from "node:crypto";
|
|
2770
2805
|
import { Pool, types as pgTypes } from "pg";
|
|
2771
2806
|
|
|
2772
2807
|
// packages/worker-core/src/index.ts
|
|
2773
|
-
import
|
|
2808
|
+
import crypto3 from "node:crypto";
|
|
2774
2809
|
|
|
2775
2810
|
// packages/worker-core/src/mcp-audit.ts
|
|
2776
2811
|
var MCP_AUDIT_DISCLAIMER = "This is a static risk review, not proof that an MCP server is secure.";
|
|
@@ -3308,21 +3343,21 @@ function assertFrozenSetJobIntegrity(job) {
|
|
|
3308
3343
|
if (member.before[job.target.tenant_guard.column] !== job.target.tenant_guard.value) throw new Error("SET_TENANT_GUARD_MISMATCH");
|
|
3309
3344
|
const expectedAfter = { ...member.before, ...job.patch, [job.version_advance.column]: member.expected_version.value + 1 };
|
|
3310
3345
|
if (!recordsEqual(member.after, expectedAfter)) throw new Error("SET_AFTER_STATE_MISMATCH");
|
|
3311
|
-
if (member.before_digest
|
|
3312
|
-
if (member.after_digest
|
|
3346
|
+
if (!reviewedDigestMatches(member.before_digest, { primary_key: member.primary_key.value, before: member.before })) throw new Error("SET_BEFORE_DIGEST_MISMATCH");
|
|
3347
|
+
if (!reviewedDigestMatches(member.after_digest, { primary_key: member.primary_key.value, after: member.after })) throw new Error("SET_AFTER_DIGEST_MISMATCH");
|
|
3313
3348
|
} else if (job.operation === "set_delete") {
|
|
3314
3349
|
if (!member.expected_version || member.before[member.expected_version.column] !== member.expected_version.value) throw new Error("SET_VERSION_GUARD_MISMATCH");
|
|
3315
3350
|
if (member.before[job.target.tenant_guard.column] !== job.target.tenant_guard.value) throw new Error("SET_TENANT_GUARD_MISMATCH");
|
|
3316
3351
|
if (Object.keys(member.after).length !== 0) throw new Error("SET_DELETE_PATCH_FORBIDDEN");
|
|
3317
|
-
if (member.before_digest
|
|
3318
|
-
if (member.tombstone_digest
|
|
3352
|
+
if (!reviewedDigestMatches(member.before_digest, { primary_key: member.primary_key.value, before: member.before })) throw new Error("SET_BEFORE_DIGEST_MISMATCH");
|
|
3353
|
+
if (!reviewedDigestMatches(member.tombstone_digest, { primary_key: member.primary_key.value, expected_version: member.expected_version })) throw new Error("SET_TOMBSTONE_DIGEST_MISMATCH");
|
|
3319
3354
|
} else {
|
|
3320
3355
|
const components = member.deduplication?.components ?? [];
|
|
3321
3356
|
const primary = components.find((component) => component.column === job.target.primary_key.column);
|
|
3322
3357
|
const tenant = components.find((component) => component.column === job.target.tenant_guard.column);
|
|
3323
3358
|
if (!primary || primary.value !== member.primary_key.value || !tenant || tenant.source !== "trusted_tenant" || tenant.value !== job.target.tenant_guard.value) throw new Error("BATCH_DEDUP_REQUIRED");
|
|
3324
3359
|
if (member.after[job.target.primary_key.column] !== member.primary_key.value || member.after[job.target.tenant_guard.column] !== job.target.tenant_guard.value) throw new Error("BATCH_IDENTITY_MISMATCH");
|
|
3325
|
-
if (member.after_digest
|
|
3360
|
+
if (!reviewedDigestMatches(member.after_digest, { primary_key: member.primary_key.value, after: member.after })) throw new Error("SET_AFTER_DIGEST_MISMATCH");
|
|
3326
3361
|
}
|
|
3327
3362
|
}
|
|
3328
3363
|
for (const bound of set.aggregate_bounds) {
|
|
@@ -3333,8 +3368,24 @@ function assertFrozenSetJobIntegrity(job) {
|
|
|
3333
3368
|
}, 0);
|
|
3334
3369
|
if (actual !== bound.actual || actual > bound.maximum) throw new Error("SET_AGGREGATE_BOUND_MISMATCH");
|
|
3335
3370
|
}
|
|
3336
|
-
const
|
|
3337
|
-
|
|
3371
|
+
const setMaterial = { operation: job.operation, members: set.members, aggregate_bounds: set.aggregate_bounds };
|
|
3372
|
+
const legacyContractMaterial = {
|
|
3373
|
+
operation: job.operation,
|
|
3374
|
+
members: set.members,
|
|
3375
|
+
aggregate_bounds: set.aggregate_bounds.map((bound) => ({
|
|
3376
|
+
column: bound.column,
|
|
3377
|
+
maximum: bound.maximum,
|
|
3378
|
+
measure: bound.measure,
|
|
3379
|
+
actual: bound.actual
|
|
3380
|
+
}))
|
|
3381
|
+
};
|
|
3382
|
+
if (!reviewedDigestMatches(set.set_digest, setMaterial, legacyContractMaterial)) throw new Error("SET_DIGEST_MISMATCH");
|
|
3383
|
+
}
|
|
3384
|
+
function reviewedDigestMatches(actual, current, legacyContract) {
|
|
3385
|
+
if (!actual) return false;
|
|
3386
|
+
const expected = /* @__PURE__ */ new Set([canonicalJsonDigest(current), reconciliationDigest(current)]);
|
|
3387
|
+
if (legacyContract) expected.add(reconciliationDigest(legacyContract));
|
|
3388
|
+
return expected.has(actual);
|
|
3338
3389
|
}
|
|
3339
3390
|
function recordValuesMatch(actual, expected, valuesEqual) {
|
|
3340
3391
|
return Object.entries(expected).every(([column, value]) => valuesEqual(actual[column], value));
|
|
@@ -3352,7 +3403,7 @@ function asScalar(value) {
|
|
|
3352
3403
|
return value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : String(value);
|
|
3353
3404
|
}
|
|
3354
3405
|
function reconciliationDigest(value) {
|
|
3355
|
-
return `sha256:${
|
|
3406
|
+
return `sha256:${crypto3.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
|
|
3356
3407
|
}
|
|
3357
3408
|
function loadConfig(env = process.env) {
|
|
3358
3409
|
const engine = (env.SYNAPSOR_ENGINE || "postgres").toLowerCase();
|
|
@@ -3606,7 +3657,7 @@ function scalar2(value) {
|
|
|
3606
3657
|
return value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : String(value);
|
|
3607
3658
|
}
|
|
3608
3659
|
function digest(value) {
|
|
3609
|
-
return `sha256:${
|
|
3660
|
+
return `sha256:${crypto4.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
|
|
3610
3661
|
}
|
|
3611
3662
|
function reconciliationProjection(job) {
|
|
3612
3663
|
const columns = /* @__PURE__ */ new Set([job.target.primary_key.column, job.target.tenant_guard.column, ...job.allowed_columns]);
|
|
@@ -10897,7 +10948,7 @@ async function handleStreamableHttpMcpRequest(input) {
|
|
|
10897
10948
|
}
|
|
10898
10949
|
let session;
|
|
10899
10950
|
const transport = new StreamableHTTPServerTransport({
|
|
10900
|
-
sessionIdGenerator: () =>
|
|
10951
|
+
sessionIdGenerator: () => crypto5.randomUUID(),
|
|
10901
10952
|
onsessioninitialized: (newSessionId) => {
|
|
10902
10953
|
if (session) {
|
|
10903
10954
|
session.sessionId = newSessionId;
|
|
@@ -11038,7 +11089,7 @@ function validBearerToken(header, expected) {
|
|
|
11038
11089
|
const actual = header.slice("Bearer ".length);
|
|
11039
11090
|
const actualBuffer = Buffer.from(actual);
|
|
11040
11091
|
const expectedBuffer = Buffer.from(expected);
|
|
11041
|
-
return actualBuffer.length === expectedBuffer.length &&
|
|
11092
|
+
return actualBuffer.length === expectedBuffer.length && crypto5.timingSafeEqual(actualBuffer, expectedBuffer);
|
|
11042
11093
|
}
|
|
11043
11094
|
function resolveMetricsEndpointAccess(config, env, host) {
|
|
11044
11095
|
if (config.metrics?.enabled !== true) return { enabled: false };
|
|
@@ -11178,7 +11229,7 @@ async function checkRunnerReadiness(config, env = process.env, timeoutMs = 3e3)
|
|
|
11178
11229
|
const table = `${quotePostgresIdentifier(shared.schema ?? "synapsor_runner")}.ledger_entries`;
|
|
11179
11230
|
await client.query(
|
|
11180
11231
|
`INSERT INTO ${table} (entry_key, kind, payload_json) VALUES ($1, 'readiness_probe', '{}'::jsonb)`,
|
|
11181
|
-
[`readiness:${
|
|
11232
|
+
[`readiness:${crypto5.randomUUID()}`]
|
|
11182
11233
|
);
|
|
11183
11234
|
await client.query("ROLLBACK");
|
|
11184
11235
|
} catch (error) {
|
|
@@ -11274,7 +11325,7 @@ function bearerToken(header) {
|
|
|
11274
11325
|
return token || void 0;
|
|
11275
11326
|
}
|
|
11276
11327
|
function tokenFingerprint(token) {
|
|
11277
|
-
return `sha256:${
|
|
11328
|
+
return `sha256:${crypto5.createHash("sha256").update(token).digest("hex")}`;
|
|
11278
11329
|
}
|
|
11279
11330
|
function safeSessionClaim(value) {
|
|
11280
11331
|
if (typeof value !== "string") return void 0;
|
|
@@ -11338,7 +11389,7 @@ function toolNameExposureMap(canonicalNames, style) {
|
|
|
11338
11389
|
return exposedByCanonical;
|
|
11339
11390
|
}
|
|
11340
11391
|
function shortToolHash(value) {
|
|
11341
|
-
return
|
|
11392
|
+
return crypto5.createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
11342
11393
|
}
|
|
11343
11394
|
function setCorsHeaders(response, corsOrigin) {
|
|
11344
11395
|
if (corsOrigin) {
|
|
@@ -12339,7 +12390,7 @@ function buildBoundedSetChangeSet(input) {
|
|
|
12339
12390
|
primary_key: { column: input.capability.target.primary_key, value: primary.value },
|
|
12340
12391
|
before: {},
|
|
12341
12392
|
after,
|
|
12342
|
-
after_digest:
|
|
12393
|
+
after_digest: canonicalJsonDigest({ primary_key: primary.value, after }),
|
|
12343
12394
|
deduplication
|
|
12344
12395
|
};
|
|
12345
12396
|
}) : input.currentRows.map((rawRow) => {
|
|
@@ -12354,8 +12405,8 @@ function buildBoundedSetChangeSet(input) {
|
|
|
12354
12405
|
expected_version: expectedVersion,
|
|
12355
12406
|
before,
|
|
12356
12407
|
after: {},
|
|
12357
|
-
before_digest:
|
|
12358
|
-
tombstone_digest:
|
|
12408
|
+
before_digest: canonicalJsonDigest({ primary_key: primaryValue, before }),
|
|
12409
|
+
tombstone_digest: canonicalJsonDigest({ primary_key: primaryValue, expected_version: expectedVersion })
|
|
12359
12410
|
};
|
|
12360
12411
|
}
|
|
12361
12412
|
const after = { ...before, ...input.patch };
|
|
@@ -12369,15 +12420,20 @@ function buildBoundedSetChangeSet(input) {
|
|
|
12369
12420
|
expected_version: expectedVersion,
|
|
12370
12421
|
before,
|
|
12371
12422
|
after,
|
|
12372
|
-
before_digest:
|
|
12373
|
-
after_digest:
|
|
12423
|
+
before_digest: canonicalJsonDigest({ primary_key: primaryValue, before }),
|
|
12424
|
+
after_digest: canonicalJsonDigest({ primary_key: primaryValue, after })
|
|
12374
12425
|
};
|
|
12375
12426
|
});
|
|
12376
12427
|
const members = rawMembers.sort((left, right) => JSON.stringify(left.primary_key.value).localeCompare(JSON.stringify(right.primary_key.value)));
|
|
12377
12428
|
if (new Set(members.map((member) => JSON.stringify(member.primary_key.value))).size !== members.length) {
|
|
12378
12429
|
throw new McpRuntimeError("SET_IDENTITY_NOT_UNIQUE", "Every frozen set member must have a unique primary-key identity.");
|
|
12379
12430
|
}
|
|
12380
|
-
const aggregateBounds = operation.aggregate_bounds.map((bound) => ({
|
|
12431
|
+
const aggregateBounds = operation.aggregate_bounds.map((bound) => ({
|
|
12432
|
+
column: bound.column,
|
|
12433
|
+
measure: bound.measure,
|
|
12434
|
+
maximum: bound.maximum,
|
|
12435
|
+
actual: aggregateValue(members, bound)
|
|
12436
|
+
}));
|
|
12381
12437
|
for (const bound of aggregateBounds) {
|
|
12382
12438
|
if (bound.actual > bound.maximum) throw new McpRuntimeError("SET_AGGREGATE_BOUND_EXCEEDED", `${bound.measure} aggregate for ${bound.column} exceeds the reviewed maximum ${bound.maximum}.`);
|
|
12383
12439
|
}
|
|
@@ -12386,7 +12442,7 @@ function buildBoundedSetChangeSet(input) {
|
|
|
12386
12442
|
row_count: members.length,
|
|
12387
12443
|
aggregate_bounds: aggregateBounds,
|
|
12388
12444
|
members,
|
|
12389
|
-
set_digest:
|
|
12445
|
+
set_digest: canonicalJsonDigest({ operation: kind, members, aggregate_bounds: aggregateBounds })
|
|
12390
12446
|
};
|
|
12391
12447
|
const approvalMode = input.capability.approval?.mode === "operator" ? "operator" : "human";
|
|
12392
12448
|
const proposalCore = {
|
|
@@ -12512,7 +12568,7 @@ var RuntimeRateLimiter = class {
|
|
|
12512
12568
|
if (this.sharedPool && this.sharedSchema) {
|
|
12513
12569
|
await this.migration;
|
|
12514
12570
|
const table = `${quotePostgresIdentifier(this.sharedSchema)}.rate_limit_buckets`;
|
|
12515
|
-
const bucketKey =
|
|
12571
|
+
const bucketKey = crypto5.createHash("sha256").update(`${context.tenant_id}\0${capability}`).digest("hex");
|
|
12516
12572
|
const result = await this.sharedPool.query(
|
|
12517
12573
|
`INSERT INTO ${table} AS bucket (bucket_key, window_start, request_count, rejected_count)
|
|
12518
12574
|
VALUES ($1, $2, 1, 0)
|
|
@@ -13102,10 +13158,10 @@ function proposalAlreadyExists(existing) {
|
|
|
13102
13158
|
);
|
|
13103
13159
|
}
|
|
13104
13160
|
function stableId(prefix, input) {
|
|
13105
|
-
return `${prefix}_${
|
|
13161
|
+
return `${prefix}_${crypto5.createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 20)}`;
|
|
13106
13162
|
}
|
|
13107
13163
|
function hashJson(input) {
|
|
13108
|
-
return `sha256:${
|
|
13164
|
+
return `sha256:${crypto5.createHash("sha256").update(JSON.stringify(input)).digest("hex")}`;
|
|
13109
13165
|
}
|
|
13110
13166
|
function isRecord5(value) {
|
|
13111
13167
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -13127,7 +13183,7 @@ function toolErrorPayload(error) {
|
|
|
13127
13183
|
}
|
|
13128
13184
|
|
|
13129
13185
|
// packages/mysql/src/index.ts
|
|
13130
|
-
import
|
|
13186
|
+
import crypto6 from "node:crypto";
|
|
13131
13187
|
import mysql2 from "mysql2/promise";
|
|
13132
13188
|
var mysqlReceiptMigration = `CREATE TABLE IF NOT EXISTS synapsor_writeback_receipts (
|
|
13133
13189
|
idempotency_key varchar(255) PRIMARY KEY,
|
|
@@ -13241,7 +13297,7 @@ function scalar4(value) {
|
|
|
13241
13297
|
return value == null || typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? value : String(value);
|
|
13242
13298
|
}
|
|
13243
13299
|
function digest2(value) {
|
|
13244
|
-
return `sha256:${
|
|
13300
|
+
return `sha256:${crypto6.createHash("sha256").update(JSON.stringify(value)).digest("hex")}`;
|
|
13245
13301
|
}
|
|
13246
13302
|
function resultHash2(job, status, version) {
|
|
13247
13303
|
return digest2({ job_id: job.job_id, status, version });
|
|
@@ -15621,7 +15677,7 @@ function parseList(value) {
|
|
|
15621
15677
|
}
|
|
15622
15678
|
function unsupported(item, blockKind) {
|
|
15623
15679
|
if (/ROOT\s+EXTERNAL|JOIN\s+EXTERNAL|RETURN\s+ANSWER|AUTO\s+BRANCH|AUTO\s+MERGE/i.test(item.text)) {
|
|
15624
|
-
throw dslError(item.line, 1, "UNSUPPORTED_PREVIEW_SYNTAX", `${blockKind} clause is not supported by @synapsor/dsl
|
|
15680
|
+
throw dslError(item.line, 1, "UNSUPPORTED_PREVIEW_SYNTAX", `${blockKind} clause is not supported by the current @synapsor/dsl grammar: ${item.text}`);
|
|
15625
15681
|
}
|
|
15626
15682
|
throw dslError(item.line, 1, "UNSUPPORTED_DSL_CLAUSE", `unsupported ${blockKind} clause: ${item.text}`);
|
|
15627
15683
|
}
|
|
@@ -15630,7 +15686,7 @@ function dslError(line, column, code, message2) {
|
|
|
15630
15686
|
}
|
|
15631
15687
|
|
|
15632
15688
|
// apps/runner/src/local-ui.ts
|
|
15633
|
-
import
|
|
15689
|
+
import crypto7 from "node:crypto";
|
|
15634
15690
|
import fs3 from "node:fs/promises";
|
|
15635
15691
|
import { createServer as createServer2 } from "node:http";
|
|
15636
15692
|
import path3 from "node:path";
|
|
@@ -15641,8 +15697,8 @@ async function startLocalUiServer(options = {}) {
|
|
|
15641
15697
|
}
|
|
15642
15698
|
const configPath = path3.resolve(options.configPath ?? "synapsor.runner.json");
|
|
15643
15699
|
const storePath = path3.resolve(options.storePath ?? "./.synapsor/local.db");
|
|
15644
|
-
const token = options.token ??
|
|
15645
|
-
const csrfToken = options.csrfToken ??
|
|
15700
|
+
const token = options.token ?? crypto7.randomBytes(24).toString("base64url");
|
|
15701
|
+
const csrfToken = options.csrfToken ?? crypto7.randomBytes(24).toString("base64url");
|
|
15646
15702
|
const storeAccess = options.storeAccess ?? localStoreAccess(storePath);
|
|
15647
15703
|
const server = createServer2(async (request, response) => {
|
|
15648
15704
|
try {
|
|
@@ -16606,7 +16662,7 @@ function stringOrDefault(value, fallback) {
|
|
|
16606
16662
|
}
|
|
16607
16663
|
|
|
16608
16664
|
// apps/runner/src/operator-identity.ts
|
|
16609
|
-
import
|
|
16665
|
+
import crypto8 from "node:crypto";
|
|
16610
16666
|
import fs4 from "node:fs/promises";
|
|
16611
16667
|
import path4 from "node:path";
|
|
16612
16668
|
async function resolveOperatorIdentity(input) {
|
|
@@ -16692,8 +16748,8 @@ async function resolveOperatorIdentity(input) {
|
|
|
16692
16748
|
const decision = operatorDecision(input, subject);
|
|
16693
16749
|
const canonical = stableJson(decision);
|
|
16694
16750
|
const decisionHash = sha2562(canonical);
|
|
16695
|
-
const signature =
|
|
16696
|
-
if (!
|
|
16751
|
+
const signature = crypto8.sign("sha256", Buffer.from(canonical), privateKey).toString("base64url");
|
|
16752
|
+
if (!crypto8.verify("sha256", Buffer.from(canonical), publicKey, Buffer.from(signature, "base64url"))) {
|
|
16697
16753
|
throw new Error(`private key does not match the reviewed public key for operator ${subject}`);
|
|
16698
16754
|
}
|
|
16699
16755
|
const core = {
|
|
@@ -16717,7 +16773,7 @@ function verifyJwtOperatorProof(proof, attestationSecret) {
|
|
|
16717
16773
|
const expected = hmacAttestation(unsigned, attestationSecret);
|
|
16718
16774
|
const actualBuffer = Buffer.from(signature);
|
|
16719
16775
|
const expectedBuffer = Buffer.from(expected);
|
|
16720
|
-
if (actualBuffer.length !== expectedBuffer.length || !
|
|
16776
|
+
if (actualBuffer.length !== expectedBuffer.length || !crypto8.timingSafeEqual(actualBuffer, expectedBuffer)) return false;
|
|
16721
16777
|
const core = { ...unsigned, signature };
|
|
16722
16778
|
return proof.integrity_hash === sha2562(stableJson(core));
|
|
16723
16779
|
}
|
|
@@ -16727,7 +16783,7 @@ function verifySignedOperatorProof(proof, publicKey) {
|
|
|
16727
16783
|
if (proof.decision_hash !== sha2562(canonical)) return false;
|
|
16728
16784
|
const { integrity_hash: _integrity, ...core } = proof;
|
|
16729
16785
|
if (proof.integrity_hash !== sha2562(stableJson(core))) return false;
|
|
16730
|
-
return
|
|
16786
|
+
return crypto8.verify("sha256", Buffer.from(canonical), publicKey, Buffer.from(proof.signature, "base64url"));
|
|
16731
16787
|
}
|
|
16732
16788
|
function operatorDecision(input, subject) {
|
|
16733
16789
|
return {
|
|
@@ -16781,10 +16837,10 @@ function rolesFromClaim(value) {
|
|
|
16781
16837
|
return [...new Set(roles)].sort();
|
|
16782
16838
|
}
|
|
16783
16839
|
function hmacAttestation(value, secret) {
|
|
16784
|
-
return
|
|
16840
|
+
return crypto8.createHmac("sha256", secret).update(stableJson(value)).digest("base64url");
|
|
16785
16841
|
}
|
|
16786
16842
|
function sha2562(value) {
|
|
16787
|
-
return `sha256:${
|
|
16843
|
+
return `sha256:${crypto8.createHash("sha256").update(value).digest("hex")}`;
|
|
16788
16844
|
}
|
|
16789
16845
|
function stableJson(value) {
|
|
16790
16846
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
@@ -16883,7 +16939,7 @@ function isRecord7(value) {
|
|
|
16883
16939
|
// apps/runner/package.json
|
|
16884
16940
|
var package_default = {
|
|
16885
16941
|
name: "@synapsor/runner",
|
|
16886
|
-
version: "1.4.
|
|
16942
|
+
version: "1.4.1",
|
|
16887
16943
|
description: "Stop giving AI agents execute_sql; expose reviewed Postgres/MySQL MCP actions with proposals, approval, writeback, and replay.",
|
|
16888
16944
|
license: "Apache-2.0",
|
|
16889
16945
|
type: "module",
|
|
@@ -16918,6 +16974,7 @@ var package_default = {
|
|
|
16918
16974
|
"AGENTS.md",
|
|
16919
16975
|
"CHANGELOG.md",
|
|
16920
16976
|
"CONTRIBUTING.md",
|
|
16977
|
+
"SECURITY.md",
|
|
16921
16978
|
"LICENSE",
|
|
16922
16979
|
"NOTICE",
|
|
16923
16980
|
"THREAT_MODEL.md",
|
|
@@ -16972,8 +17029,8 @@ var package_default = {
|
|
|
16972
17029
|
// packages/dsl/package.json
|
|
16973
17030
|
var package_default2 = {
|
|
16974
17031
|
name: "@synapsor/dsl",
|
|
16975
|
-
version: "1.4.
|
|
16976
|
-
description: "
|
|
17032
|
+
version: "1.4.1",
|
|
17033
|
+
description: "SQL-like Synapsor authoring frontend for canonical @synapsor/spec contracts.",
|
|
16977
17034
|
license: "Apache-2.0",
|
|
16978
17035
|
type: "module",
|
|
16979
17036
|
main: "dist/index.js",
|
|
@@ -20374,8 +20431,8 @@ function trustedCliContext(config, capability, env) {
|
|
|
20374
20431
|
return { tenant_id: tenant, principal };
|
|
20375
20432
|
}
|
|
20376
20433
|
function createCompensationProposal(input) {
|
|
20377
|
-
const proposalId = `wrp_revert_${
|
|
20378
|
-
const evidenceId = `ev_revert_${
|
|
20434
|
+
const proposalId = `wrp_revert_${crypto9.randomBytes(10).toString("hex")}`;
|
|
20435
|
+
const evidenceId = `ev_revert_${crypto9.randomBytes(10).toString("hex")}`;
|
|
20379
20436
|
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
20380
20437
|
const one = input.inverse.members.length === 1 ? input.inverse.members[0] : void 0;
|
|
20381
20438
|
const before = one ? one.expected_state : { row_count: input.inverse.members.length };
|
|
@@ -20845,7 +20902,7 @@ function executorConfig(config, executorName) {
|
|
|
20845
20902
|
throw new Error(`executor ${executorName} has unsupported type`);
|
|
20846
20903
|
}
|
|
20847
20904
|
function signHandlerRequestBody(body, secret) {
|
|
20848
|
-
return `sha256=${
|
|
20905
|
+
return `sha256=${crypto9.createHmac("sha256", secret).update(body).digest("hex")}`;
|
|
20849
20906
|
}
|
|
20850
20907
|
async function applyHttpHandlerProposal(input) {
|
|
20851
20908
|
const duplicate = duplicateHandlerReceipt(input.store, input.proposalId);
|
|
@@ -21050,7 +21107,7 @@ function buildHandlerReceipt(input) {
|
|
|
21050
21107
|
};
|
|
21051
21108
|
}
|
|
21052
21109
|
function hashReceipt(input) {
|
|
21053
|
-
return `sha256:${
|
|
21110
|
+
return `sha256:${crypto9.createHash("sha256").update(JSON.stringify(input)).digest("hex")}`;
|
|
21054
21111
|
}
|
|
21055
21112
|
function formatApplyResult(job, result, dryRun, storePath) {
|
|
21056
21113
|
const status = writebackResultStatus(result);
|
|
@@ -22113,7 +22170,7 @@ function reconciliationReceipt(intent, observation, outcome, runnerId, reason) {
|
|
|
22113
22170
|
};
|
|
22114
22171
|
return {
|
|
22115
22172
|
...base2,
|
|
22116
|
-
receipt_hash: `sha256:${
|
|
22173
|
+
receipt_hash: `sha256:${crypto9.createHash("sha256").update(JSON.stringify(base2)).digest("hex")}`
|
|
22117
22174
|
};
|
|
22118
22175
|
}
|
|
22119
22176
|
if (job.protocol_version !== protocolVersions.normalizedWritebackJobV2) throw new Error("single-row reconciliation requires a writeback-job v2");
|
|
@@ -22140,7 +22197,7 @@ function reconciliationReceipt(intent, observation, outcome, runnerId, reason) {
|
|
|
22140
22197
|
};
|
|
22141
22198
|
return {
|
|
22142
22199
|
...base,
|
|
22143
|
-
receipt_hash: `sha256:${
|
|
22200
|
+
receipt_hash: `sha256:${crypto9.createHash("sha256").update(JSON.stringify(base)).digest("hex")}`
|
|
22144
22201
|
};
|
|
22145
22202
|
}
|
|
22146
22203
|
function publicWritebackIntent(intent) {
|
|
@@ -25616,7 +25673,7 @@ no rows deleted
|
|
|
25616
25673
|
await pool.query(`DELETE FROM ${qualified} WHERE entry_key = ANY($1::text[])`, [archivedEntries.map((entry) => entry.entry_key)]);
|
|
25617
25674
|
}
|
|
25618
25675
|
const retentionEntry = {
|
|
25619
|
-
entry_key: `retention:${
|
|
25676
|
+
entry_key: `retention:${crypto9.randomUUID()}`,
|
|
25620
25677
|
kind: "retention_event",
|
|
25621
25678
|
payload: {
|
|
25622
25679
|
cutoff,
|
|
@@ -27647,7 +27704,7 @@ function toExecutionReceipt(job, result, dryRun) {
|
|
|
27647
27704
|
const affectedRows2 = result.affected_rows ?? 0;
|
|
27648
27705
|
const terminalStatus = result.status === "applied" && !dryRun && affectedRows2 === 0 ? "already_applied" : result.status;
|
|
27649
27706
|
const previousVersion = job.conflict_guard.kind === "version_column" ? job.conflict_guard.expected_value : void 0;
|
|
27650
|
-
const receiptHash = typeof result.result_hash === "string" && result.result_hash.startsWith("sha256:") ? result.result_hash : `sha256:${
|
|
27707
|
+
const receiptHash = typeof result.result_hash === "string" && result.result_hash.startsWith("sha256:") ? result.result_hash : `sha256:${crypto9.createHash("sha256").update(JSON.stringify({
|
|
27651
27708
|
job_id: job.job_id,
|
|
27652
27709
|
status: terminalStatus,
|
|
27653
27710
|
affected_rows: affectedRows2,
|
package/docs/README.md
CHANGED
|
@@ -24,6 +24,10 @@ no-database demo, wire your database, then read deeper concepts.
|
|
|
24
24
|
## 02 Why Raw SQL Is Dangerous
|
|
25
25
|
|
|
26
26
|
- [Security Boundary](security-boundary.md): what the model can and cannot see.
|
|
27
|
+
- [Why Synapsor Over Prompt And Application
|
|
28
|
+
Guardrails](why-synapsor-vs-app-guardrails.md): where SQL authority lives,
|
|
29
|
+
what a hand-built semantic tool already gets right, and when the shared
|
|
30
|
+
contract, approval, receipt, and replay layer is worth adopting.
|
|
27
31
|
- [MCP Audit](mcp-audit.md): static review for risky database MCP tools such as
|
|
28
32
|
`execute_sql`, broad query tools, model-controlled tenant filters, or
|
|
29
33
|
model-facing approval/commit tools.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Bounded Set Local Measurements
|
|
2
|
+
|
|
3
|
+
These measurements are test evidence, not throughput claims or capacity
|
|
4
|
+
guidance. They record one run of `corepack pnpm test:bounded-set` on 2026-07-13
|
|
5
|
+
using synthetic rows and disposable local Docker databases.
|
|
6
|
+
|
|
7
|
+
Environment:
|
|
8
|
+
|
|
9
|
+
- Linux laptop, Intel Core i7-13800H, 20 logical CPUs, 30 GiB RAM;
|
|
10
|
+
- Node.js 22.22.2 and pnpm 10.14.0;
|
|
11
|
+
- Docker 29.5.2;
|
|
12
|
+
- PostgreSQL 16 and MySQL 8 containers;
|
|
13
|
+
- source-database receipt authority with an administrator-precreated receipt
|
|
14
|
+
table;
|
|
15
|
+
- one sequential set UPDATE per measurement, including connection setup,
|
|
16
|
+
source receipt, lock/preflight, transaction, and exact receipt generation.
|
|
17
|
+
|
|
18
|
+
| Engine | 1 row | 10 rows | 100 rows |
|
|
19
|
+
| --- | ---: | ---: | ---: |
|
|
20
|
+
| PostgreSQL | 30.42 ms | 53.88 ms | 154.90 ms |
|
|
21
|
+
| MySQL | 25.30 ms | 53.91 ms | 163.20 ms |
|
|
22
|
+
|
|
23
|
+
These figures are not portable across hardware, network topology, source
|
|
24
|
+
indexes, lock contention, triggers, database configuration, or receipt mode.
|
|
25
|
+
The claim supported by this gate is only that execution stays bounded and the
|
|
26
|
+
hard 100-row ceiling is exercised on both adapters. Operators must benchmark
|
|
27
|
+
their own reviewed contracts and database topology.
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# Bounded Set Writeback
|
|
2
|
+
|
|
3
|
+
Synapsor Runner can apply a deliberately narrow class of reviewed
|
|
4
|
+
multi-row database changes to PostgreSQL and MySQL:
|
|
5
|
+
|
|
6
|
+
- fixed-predicate set `UPDATE`;
|
|
7
|
+
- fixed-predicate set `DELETE`;
|
|
8
|
+
- exact-review batch `INSERT`.
|
|
9
|
+
|
|
10
|
+
This is not model-generated SQL and it is not a generic batch API. The model
|
|
11
|
+
can ask to run a reviewed business action, but it cannot choose a table,
|
|
12
|
+
column, operator, ordering, predicate, tenant, or unbounded set.
|
|
13
|
+
|
|
14
|
+
## Safety invariant
|
|
15
|
+
|
|
16
|
+
The worst action available to a prompt-injected model is to request one
|
|
17
|
+
contract-authored rule, against the trusted tenant, up to the reviewer-authored
|
|
18
|
+
row and value caps. Before approval, Runner freezes the exact ordered members.
|
|
19
|
+
Apply later touches only those members in one transaction and records every
|
|
20
|
+
identity and bounded digest.
|
|
21
|
+
|
|
22
|
+
Every bounded set requires:
|
|
23
|
+
|
|
24
|
+
1. a fixed typed selection for existing-row UPDATE/DELETE, or the complete
|
|
25
|
+
reviewed item array for batch INSERT;
|
|
26
|
+
2. `MAX ROWS n`, where `1 <= n <= 100`;
|
|
27
|
+
3. at least one aggregate `MAX TOTAL` bound;
|
|
28
|
+
4. trusted tenant binding;
|
|
29
|
+
5. exact version guards and integer advancement for set UPDATE;
|
|
30
|
+
6. source-unique per-item identities for batch INSERT;
|
|
31
|
+
7. human/operator approval outside MCP;
|
|
32
|
+
8. one atomic source transaction and an exact affected-row count;
|
|
33
|
+
9. source or Runner-ledger receipts with the documented crash semantics.
|
|
34
|
+
|
|
35
|
+
A row cap is a rejection threshold, never `LIMIT n` truncation. Proposal reads
|
|
36
|
+
fetch at most `n + 1`; finding the extra row rejects before proposal
|
|
37
|
+
persistence. Aggregate overflow also rejects before persistence.
|
|
38
|
+
|
|
39
|
+
## DSL examples
|
|
40
|
+
|
|
41
|
+
Fixed set UPDATE:
|
|
42
|
+
|
|
43
|
+
```sql
|
|
44
|
+
PROPOSE ACTION close_overdue UPDATE SET
|
|
45
|
+
SELECT WHERE status = 'overdue'
|
|
46
|
+
MAX ROWS 10
|
|
47
|
+
MAX TOTAL balance_cents BEFORE 50000
|
|
48
|
+
ALLOW WRITE status
|
|
49
|
+
PATCH status = 'closed'
|
|
50
|
+
ADVANCE VERSION version USING INTEGER INCREMENT
|
|
51
|
+
APPROVAL ROLE billing_reviewer
|
|
52
|
+
WRITEBACK DIRECT SQL
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The selection is a contract literal. It is not an argument and is never taken
|
|
56
|
+
from the model. The first release supports equality terms joined by `AND`.
|
|
57
|
+
|
|
58
|
+
Exact-review batch INSERT:
|
|
59
|
+
|
|
60
|
+
```sql
|
|
61
|
+
ARG items ROWS MAX 10 REQUIRED
|
|
62
|
+
ITEM FIELD items.id STRING REQUIRED MAX LENGTH 128
|
|
63
|
+
ITEM FIELD items.external_id STRING REQUIRED MAX LENGTH 128
|
|
64
|
+
ITEM FIELD items.amount_cents NUMBER REQUIRED MIN 1 MAX 2500
|
|
65
|
+
ITEM FIELD items.reason STRING REQUIRED MAX LENGTH 500
|
|
66
|
+
|
|
67
|
+
PROPOSE ACTION create_credits INSERT SET
|
|
68
|
+
BATCH ITEMS FROM ARG items
|
|
69
|
+
MAX ROWS 10
|
|
70
|
+
MAX TOTAL amount_cents AFTER 25000
|
|
71
|
+
DEDUP KEY tenant_id = TRUSTED TENANT, id = ITEM id, external_id = ITEM external_id
|
|
72
|
+
ALLOW WRITE amount_cents, reason
|
|
73
|
+
PATCH amount_cents = ITEM amount_cents
|
|
74
|
+
PATCH reason = ITEM reason
|
|
75
|
+
APPROVAL ROLE billing_reviewer
|
|
76
|
+
WRITEBACK DIRECT SQL
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The reviewer sees every allowlisted candidate item before approval. The source
|
|
80
|
+
must enforce the declared identity with a primary or unique constraint.
|
|
81
|
+
|
|
82
|
+
Set DELETE uses `DELETE SET`, a fixed selection, `MAX ROWS`, `MAX TOTAL`, and
|
|
83
|
+
an exact conflict guard. It has no patch. Runner rejects hard DELETE if it
|
|
84
|
+
cannot prove trigger/FK visibility or detects write triggers or widening
|
|
85
|
+
cascades. Prefer a set UPDATE of `deleted_at` or status when possible.
|
|
86
|
+
|
|
87
|
+
## Worked contract-to-apply example
|
|
88
|
+
|
|
89
|
+
This PostgreSQL example closes two overdue tickets. MySQL is supported with
|
|
90
|
+
the same contract semantics; change the source engine and schema/database name
|
|
91
|
+
to match the inspected MySQL object.
|
|
92
|
+
|
|
93
|
+
Create the fixture with an administrator account, then give the Runner reader
|
|
94
|
+
`SELECT` and its separate writer only `SELECT, UPDATE` on this table:
|
|
95
|
+
|
|
96
|
+
```sql
|
|
97
|
+
CREATE TABLE public.service_tickets (
|
|
98
|
+
id bigint PRIMARY KEY,
|
|
99
|
+
tenant_id text NOT NULL,
|
|
100
|
+
status text NOT NULL,
|
|
101
|
+
cost_cents integer NOT NULL,
|
|
102
|
+
version integer NOT NULL
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
INSERT INTO public.service_tickets
|
|
106
|
+
(id, tenant_id, status, cost_cents, version)
|
|
107
|
+
VALUES
|
|
108
|
+
(3, 'acme', 'overdue', 8000, 1),
|
|
109
|
+
(4, 'acme', 'overdue', 15000, 1),
|
|
110
|
+
(99, 'globex', 'overdue', 49000, 1);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Save this as `tickets.synapsor.sql`:
|
|
114
|
+
|
|
115
|
+
```sql
|
|
116
|
+
CREATE AGENT CONTEXT local_operator
|
|
117
|
+
BIND tenant_id FROM ENVIRONMENT SYNAPSOR_TENANT_ID REQUIRED
|
|
118
|
+
BIND principal FROM ENVIRONMENT SYNAPSOR_PRINCIPAL REQUIRED
|
|
119
|
+
TENANT BINDING tenant_id
|
|
120
|
+
PRINCIPAL BINDING principal
|
|
121
|
+
END
|
|
122
|
+
|
|
123
|
+
CREATE CAPABILITY tickets.close_overdue
|
|
124
|
+
USING CONTEXT local_operator
|
|
125
|
+
SOURCE local_db
|
|
126
|
+
ON public.service_tickets
|
|
127
|
+
PRIMARY KEY id
|
|
128
|
+
TENANT KEY tenant_id
|
|
129
|
+
CONFLICT GUARD version
|
|
130
|
+
LOOKUP reason BY id
|
|
131
|
+
ARG reason STRING REQUIRED MAX LENGTH 100
|
|
132
|
+
ALLOW READ id, tenant_id, status, cost_cents, version
|
|
133
|
+
REQUIRE EVIDENCE
|
|
134
|
+
PROPOSE ACTION close_overdue UPDATE SET
|
|
135
|
+
SELECT WHERE status = 'overdue'
|
|
136
|
+
MAX ROWS 10
|
|
137
|
+
MAX TOTAL cost_cents BEFORE 50000
|
|
138
|
+
ALLOW WRITE status
|
|
139
|
+
PATCH status = 'closed'
|
|
140
|
+
ADVANCE VERSION version USING INTEGER INCREMENT
|
|
141
|
+
APPROVAL ROLE ops_manager
|
|
142
|
+
WRITEBACK DIRECT SQL
|
|
143
|
+
END
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Compile the contract and create `synapsor.runner.json`:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
synapsor-runner dsl compile ./tickets.synapsor.sql \
|
|
150
|
+
--out ./synapsor.contract.json --strict
|
|
151
|
+
synapsor-runner contract validate ./synapsor.contract.json
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"version": 1,
|
|
157
|
+
"mode": "review",
|
|
158
|
+
"storage": { "sqlite_path": "./.synapsor/local.db" },
|
|
159
|
+
"sources": {
|
|
160
|
+
"local_db": {
|
|
161
|
+
"engine": "postgres",
|
|
162
|
+
"read_url_env": "SYNAPSOR_DATABASE_READ_URL",
|
|
163
|
+
"write_url_env": "SYNAPSOR_DATABASE_WRITE_URL",
|
|
164
|
+
"statement_timeout_ms": 3000,
|
|
165
|
+
"receipts": { "authority": "runner_ledger" }
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
"contracts": ["./synapsor.contract.json"]
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Load database URLs from your shell or secret manager. The config stores only
|
|
173
|
+
environment-variable names. Trusted tenant/principal values are process-owned,
|
|
174
|
+
not tool arguments:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
export SYNAPSOR_TENANT_ID=acme
|
|
178
|
+
export SYNAPSOR_PRINCIPAL=local_operator
|
|
179
|
+
|
|
180
|
+
synapsor-runner config validate --config ./synapsor.runner.json
|
|
181
|
+
synapsor-runner doctor --config ./synapsor.runner.json --check-writeback
|
|
182
|
+
synapsor-runner propose tickets.close_overdue \
|
|
183
|
+
--json '{"reason":"reviewed overdue-ticket close"}' \
|
|
184
|
+
--config ./synapsor.runner.json --store ./.synapsor/local.db
|
|
185
|
+
synapsor-runner proposals show latest --store ./.synapsor/local.db --details
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
At this point IDs `3` and `4` are frozen in the proposal, but both source rows
|
|
189
|
+
are still `overdue` at version `1`; the `globex` row is outside trusted tenant
|
|
190
|
+
scope. Approval and apply remain operator-side commands and are not MCP tools:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
synapsor-runner proposals approve latest --actor ops_manager --yes \
|
|
194
|
+
--store ./.synapsor/local.db
|
|
195
|
+
synapsor-runner apply latest --config ./synapsor.runner.json \
|
|
196
|
+
--store ./.synapsor/local.db
|
|
197
|
+
synapsor-runner receipts list --proposal <proposal_id> \
|
|
198
|
+
--store ./.synapsor/local.db
|
|
199
|
+
synapsor-runner replay show --proposal <proposal_id> --details \
|
|
200
|
+
--store ./.synapsor/local.db
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Exactly IDs `3` and `4` become `closed` at version `2` in one transaction.
|
|
204
|
+
The receipt/replay list both safe primary-key identities and their bounded
|
|
205
|
+
digests. ID `99` is unchanged.
|
|
206
|
+
|
|
207
|
+
To test drift on a freshly seeded copy, create the proposal, then use a
|
|
208
|
+
database-operator session to increment one frozen row's version before
|
|
209
|
+
approval. `apply` returns `SET_DRIFT_CONFLICT`; the other frozen row remains
|
|
210
|
+
unchanged because no partial set mutation is committed:
|
|
211
|
+
|
|
212
|
+
```sql
|
|
213
|
+
UPDATE public.service_tickets SET version = version + 1 WHERE id = 4;
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The example uses `runner_ledger`, which creates no receipt table in the source
|
|
217
|
+
database. For one local process, the local store is the authority; a fleet must
|
|
218
|
+
use authoritative shared-Postgres `runtime_store`. `source_db` authority
|
|
219
|
+
instead writes the mutation and receipt atomically in one source transaction
|
|
220
|
+
and requires its fixed precreated or auto-migrated receipt table. See
|
|
221
|
+
[Guarded CRUD receipt authority](guarded-crud-writeback.md).
|
|
222
|
+
|
|
223
|
+
## Proposal and apply behavior
|
|
224
|
+
|
|
225
|
+
Proposal creation stores:
|
|
226
|
+
|
|
227
|
+
- the exact ordered primary keys and trusted tenant;
|
|
228
|
+
- every exact expected version;
|
|
229
|
+
- allowlisted before/after values or bounded digests;
|
|
230
|
+
- reviewed row count and aggregate values;
|
|
231
|
+
- a digest over the complete frozen set;
|
|
232
|
+
- pending human/operator approval.
|
|
233
|
+
|
|
234
|
+
Member and set digests use SHA-256 over JSON whose object keys are sorted
|
|
235
|
+
recursively. Array order remains significant because it is the reviewed,
|
|
236
|
+
deterministic primary-key order. JSON strings, finite numbers, booleans, and
|
|
237
|
+
null retain their protocol representation; unsupported runtime objects and
|
|
238
|
+
non-finite values are rejected rather than stringified implicitly. Runner
|
|
239
|
+
1.4.1 also verifies the narrowly known deterministic raw-object order emitted
|
|
240
|
+
by 1.4.0, reconstructed from the complete stored reviewed data, so unchanged
|
|
241
|
+
1.4.0 proposals remain applyable without weakening the drift checks.
|
|
242
|
+
|
|
243
|
+
Apply locks the frozen rows in deterministic primary-key order, rechecks every
|
|
244
|
+
reviewed value/version, performs every member mutation in one transaction, and
|
|
245
|
+
requires the affected count to equal the frozen count. One missing/stale row or
|
|
246
|
+
one database error rolls back the whole set.
|
|
247
|
+
|
|
248
|
+
Set `sources.<name>.statement_timeout_ms` to bound source waits. PostgreSQL
|
|
249
|
+
uses transaction-local statement and lock timeouts. MySQL applies the value to
|
|
250
|
+
read/preflight execution and rounds it up to whole seconds for InnoDB lock
|
|
251
|
+
waits; MySQL does not offer the same general DML statement timeout as
|
|
252
|
+
PostgreSQL.
|
|
253
|
+
|
|
254
|
+
The protocol-v3 receipt and replay include every primary key plus the bounded
|
|
255
|
+
before/after or tombstone digests. Kept-out fields are never added merely to
|
|
256
|
+
support a set operation.
|
|
257
|
+
|
|
258
|
+
## Receipt authority and ambiguity
|
|
259
|
+
|
|
260
|
+
`source_db` authority commits the mutation and receipt in the same source
|
|
261
|
+
transaction. It gives the strongest retry classification.
|
|
262
|
+
|
|
263
|
+
`runner_ledger` authority creates no source receipt table, but no atomic
|
|
264
|
+
transaction spans the source and Runner ledger. A process loss after source
|
|
265
|
+
commit can therefore produce `reconciliation_required`. Runner does not retry
|
|
266
|
+
or guess. The operator reconciliation path re-inspects only the frozen,
|
|
267
|
+
allowlisted identities and records an immutable decision.
|
|
268
|
+
|
|
269
|
+
## Explicit boundary
|
|
270
|
+
|
|
271
|
+
Use an [app-owned executor](writeback-executors.md) for:
|
|
272
|
+
|
|
273
|
+
- model-supplied or free-form predicates;
|
|
274
|
+
- ranges, wildcards, ordering, subqueries, or dynamic identifiers;
|
|
275
|
+
- more than 100 rows or any unbounded batch;
|
|
276
|
+
- UPSERT/MERGE, DDL, stored procedures, or cross-table/database transactions;
|
|
277
|
+
- external API calls, events, files, emails, payments, or other side effects;
|
|
278
|
+
- a hard DELETE whose trigger/cascade effects cannot be proven bounded.
|
|
279
|
+
|
|
280
|
+
Policy auto-approval is not supported for bounded sets. Every set proposal
|
|
281
|
+
requires a verified human/operator decision.
|
|
282
|
+
|
|
283
|
+
## Verification
|
|
284
|
+
|
|
285
|
+
Run the disposable-engine gate:
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
corepack pnpm test:bounded-set
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
It verifies both PostgreSQL and MySQL: the exact DSL/contract path under both
|
|
292
|
+
receipt authorities; cap+1 and aggregate rejection before persistence; exact
|
|
293
|
+
UPDATE/DELETE; batch INSERT; idempotent retry; independent version, predicate,
|
|
294
|
+
aggregate, writable-value, missing-member, and tenant drift; injected mid-set
|
|
295
|
+
rollback; insert dedup/atomicity; delete trigger/cascade refusal; exact receipt
|
|
296
|
+
members; Runner-ledger reconciliation; and 1/10/100-row bounds. See the
|
|
297
|
+
[local benchmark note](benchmarks/bounded-set-local.md) and the normative
|
|
298
|
+
[safety RFC](rfcs/005-bounded-set-writeback.md).
|
|
@@ -447,3 +447,9 @@ Commit authority stays outside the model:
|
|
|
447
447
|
```text
|
|
448
448
|
human/operator approval -> guarded writeback or app-owned handler -> receipt/replay
|
|
449
449
|
```
|
|
450
|
+
|
|
451
|
+
If your application already exposes fixed parameterized functions, that is a
|
|
452
|
+
good foundation rather than an anti-pattern. Read [Why Synapsor Over Prompt And
|
|
453
|
+
Application Guardrails](why-synapsor-vs-app-guardrails.md) to decide whether
|
|
454
|
+
you also need Runner's shared contract, trusted context, evidence, approval,
|
|
455
|
+
receipt, replay, and compensation lifecycle.
|
package/docs/release-notes.md
CHANGED
|
@@ -10,6 +10,29 @@ npx -y -p @synapsor/runner synapsor-runner demo --quick
|
|
|
10
10
|
The OSS runner command is `synapsor-runner`. The `synapsor` command is reserved
|
|
11
11
|
for the Synapsor Cloud CLI.
|
|
12
12
|
|
|
13
|
+
## 1.4.1 (prepared, not published)
|
|
14
|
+
|
|
15
|
+
### Bounded-set digest compatibility patch
|
|
16
|
+
|
|
17
|
+
- Contract-authored bounded-set proposals now use deterministic recursive
|
|
18
|
+
object-key ordering for member and set digest material.
|
|
19
|
+
- Valid proposals created by `1.4.0` remain applyable; the compatibility path
|
|
20
|
+
accepts only the known deterministic `1.4.0` serializations reconstructed
|
|
21
|
+
from the complete stored reviewed data.
|
|
22
|
+
- Genuine member, version, value, aggregate, membership, or tenant drift still
|
|
23
|
+
fails closed before source mutation on PostgreSQL and MySQL.
|
|
24
|
+
- The Runner package now includes the linked bounded-set guide and validates
|
|
25
|
+
all shipped local Markdown links while packaging.
|
|
26
|
+
- The DSL package description and README no longer label the current `1.4.x`
|
|
27
|
+
package as a `0.1 preview`. Canonical contract `spec_version: "0.1"` is
|
|
28
|
+
unchanged.
|
|
29
|
+
- Adds an honest prompt-and-application-guardrails decision guide covering SQL
|
|
30
|
+
authority, hand-built semantic tools, structural enforcement, build-vs-adopt
|
|
31
|
+
fit, and regulated-data boundaries.
|
|
32
|
+
|
|
33
|
+
Prepared package versions: `@synapsor/dsl@1.4.1` and
|
|
34
|
+
`@synapsor/runner@1.4.1`. `@synapsor/spec` remains `1.4.0`.
|
|
35
|
+
|
|
13
36
|
## 1.4.0 (prepared, not published)
|
|
14
37
|
|
|
15
38
|
### Reviewed Reversible Change Sets
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Result Envelope V3
|
|
2
|
+
|
|
3
|
+
Protocol v3 is used only for bounded set direct writeback. Protocol v1 and v2
|
|
4
|
+
remain accepted for legacy UPDATE and guarded single-row CRUD.
|
|
5
|
+
|
|
6
|
+
The public artifacts are:
|
|
7
|
+
|
|
8
|
+
- `synapsor.change-set.v3`;
|
|
9
|
+
- `synapsor.writeback-job.v3`;
|
|
10
|
+
- `synapsor.execution-receipt.v3`.
|
|
11
|
+
|
|
12
|
+
Each v3 object names one operation: `set_update`, `set_delete`, or
|
|
13
|
+
`batch_insert`. It carries the same frozen-set authority:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"max_rows": 10,
|
|
18
|
+
"row_count": 2,
|
|
19
|
+
"aggregate_bounds": [
|
|
20
|
+
{ "column": "balance_cents", "measure": "before", "maximum": 20000, "actual": 15000 }
|
|
21
|
+
],
|
|
22
|
+
"members": [
|
|
23
|
+
{
|
|
24
|
+
"primary_key": { "column": "id", "value": "INV-1" },
|
|
25
|
+
"expected_version": { "column": "version", "value": 1 },
|
|
26
|
+
"before_digest": "sha256:...",
|
|
27
|
+
"after_digest": "sha256:..."
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"set_digest": "sha256:..."
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The complete protocol fixtures live in [`fixtures/protocol`](../fixtures/protocol)
|
|
35
|
+
and the JSON Schemas live in [`schemas`](../schemas). The execution receipt
|
|
36
|
+
reports every `target_identity` and matching `member_effect`, plus receipt
|
|
37
|
+
authority, set digest, exact affected count, safe status/error, and receipt
|
|
38
|
+
hash. An applied result is invalid unless its affected count and member-effect
|
|
39
|
+
count match the frozen identity count.
|
|
40
|
+
|
|
41
|
+
`runner_ledger` may return `reconciliation_required` with an intent ID when a
|
|
42
|
+
cross-database crash window prevents proof of commit. That is a terminal
|
|
43
|
+
operator state, not a retry instruction or an `already_applied` guess.
|
|
@@ -20,6 +20,13 @@ trusted context, evidence handles, query audit, local inspection, and
|
|
|
20
20
|
proposal-first writes. Proposal workflows add full replay across evidence,
|
|
21
21
|
approval, writeback receipts, and events.
|
|
22
22
|
|
|
23
|
+
Prompt instructions and application validation are separate layers. Prompts
|
|
24
|
+
can guide behavior but cannot supply authorization. A fixed, parameterized
|
|
25
|
+
application tool can be a sound boundary for a small system; Runner adds a
|
|
26
|
+
portable reviewed contract and a consistent proposal, approval, receipt,
|
|
27
|
+
replay, and compensation lifecycle. See [Why Synapsor Over Prompt And
|
|
28
|
+
Application Guardrails](why-synapsor-vs-app-guardrails.md).
|
|
29
|
+
|
|
23
30
|
The model-facing MCP server exposes reviewed semantic tools such as
|
|
24
31
|
`billing.inspect_invoice` and `billing.propose_late_fee_waiver`.
|
|
25
32
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Why Synapsor Over Prompt And Application Guardrails
|
|
2
|
+
|
|
3
|
+
You can build a safe database tool in application code. The important question
|
|
4
|
+
is not whether the code is custom or packaged. It is where authority lives and
|
|
5
|
+
which safety properties are enforced outside the model.
|
|
6
|
+
|
|
7
|
+
Synapsor Runner is useful when a narrow application function is no longer the
|
|
8
|
+
whole requirement. It adds a shared, reviewable contract and an operational
|
|
9
|
+
record around model-facing reads and writes: trusted context, evidence, query
|
|
10
|
+
audit, proposals, approval outside MCP, guarded writeback, idempotency
|
|
11
|
+
receipts, replay, and opt-in reviewed compensation.
|
|
12
|
+
|
|
13
|
+
It does not make prompts trustworthy, prevent prompt injection, replace
|
|
14
|
+
database permissions, or make an application compliant by itself.
|
|
15
|
+
|
|
16
|
+
## Prompt Instructions Are Not A Security Boundary
|
|
17
|
+
|
|
18
|
+
A system prompt can improve normal model behavior. It cannot grant or remove
|
|
19
|
+
database authority. **Prompt-only enforcement is not a security boundary.** A
|
|
20
|
+
confused or prompt-injected model can ignore an instruction, so a rule such as
|
|
21
|
+
"only access this tenant" must be enforced by trusted code, database policy,
|
|
22
|
+
or both.
|
|
23
|
+
|
|
24
|
+
This is a general agent-security issue, not a hypothetical specific to one
|
|
25
|
+
vendor:
|
|
26
|
+
|
|
27
|
+
- [OWASP LLM01: Prompt Injection](https://genai.owasp.org/llmrisk/llm01-prompt-injection/)
|
|
28
|
+
describes direct and indirect prompt injection and recommends constrained
|
|
29
|
+
model behavior plus human approval for high-risk actions.
|
|
30
|
+
- [General Analysis published a controlled Supabase MCP
|
|
31
|
+
demonstration](https://generalanalysis.com/blog/supabase-mcp-blog) in which
|
|
32
|
+
untrusted support-ticket content influenced an agent with broad database
|
|
33
|
+
access and exposed integration-token data. The demonstration disabled
|
|
34
|
+
read-only mode and used a privileged `service_role`; it was a controlled
|
|
35
|
+
security test, not evidence that every Supabase MCP deployment is
|
|
36
|
+
vulnerable.
|
|
37
|
+
- [Supabase's defense-in-depth
|
|
38
|
+
response](https://supabase.com/blog/defense-in-depth-mcp) likewise recommends
|
|
39
|
+
a protective layer, least privilege, and avoiding direct production access.
|
|
40
|
+
|
|
41
|
+
The lesson is not that a prompt can never be useful. It is that prompt text
|
|
42
|
+
must not be the control that protects tenant scope, hidden fields, approval,
|
|
43
|
+
or commit authority.
|
|
44
|
+
|
|
45
|
+
## First Ask: Who Produces The SQL?
|
|
46
|
+
|
|
47
|
+
### The model produces SQL
|
|
48
|
+
|
|
49
|
+
If the model emits arbitrary SQL and application code decides whether to run
|
|
50
|
+
it, that validator must correctly understand the database grammar,
|
|
51
|
+
subqueries, functions, views, permissions, row scope, side effects, and future
|
|
52
|
+
schema changes. A blocklist or regular expression is not an authorization
|
|
53
|
+
system. This remains the `execute_sql` problem with an extra parser in front
|
|
54
|
+
of it.
|
|
55
|
+
|
|
56
|
+
Prefer fixed, parameterized statements selected by trusted application code,
|
|
57
|
+
restricted database roles, views, and row-level security. Do not make the
|
|
58
|
+
model the query planner for privileged production access.
|
|
59
|
+
|
|
60
|
+
### Your application produces SQL
|
|
61
|
+
|
|
62
|
+
If application code selects a fixed parameterized statement and exposes a
|
|
63
|
+
narrow function such as `billing.inspect_invoice`, you have already made the
|
|
64
|
+
most important architectural move: a semantic business tool replaces raw SQL.
|
|
65
|
+
For a small application, that may be enough.
|
|
66
|
+
|
|
67
|
+
The next question is whether you also need a consistent review and operations
|
|
68
|
+
layer. Without one, each function must separately implement and preserve:
|
|
69
|
+
|
|
70
|
+
- trusted tenant and principal binding;
|
|
71
|
+
- read and write field allowlists;
|
|
72
|
+
- evidence and query-audit records;
|
|
73
|
+
- exact proposed diffs before a write;
|
|
74
|
+
- approval identity and quorum outside the model;
|
|
75
|
+
- conflict, transition, numeric, row-count, and aggregate guards;
|
|
76
|
+
- idempotent execution and receipts;
|
|
77
|
+
- activity search and replay;
|
|
78
|
+
- bounded, separately approved compensation where reversibility is possible.
|
|
79
|
+
|
|
80
|
+
Synapsor centralizes those mechanics in a contract and runtime instead of
|
|
81
|
+
requiring every tool handler to invent them independently.
|
|
82
|
+
|
|
83
|
+
## What Runner Adds
|
|
84
|
+
|
|
85
|
+
| Concern | Prompt or one-off handler | Synapsor Runner |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| Model-facing authority | Depends on instructions and handler design | Only reviewed semantic capabilities are served |
|
|
88
|
+
| Tenant/principal scope | Often passed as model arguments or scattered checks | Bound from trusted environment or authenticated session context |
|
|
89
|
+
| Data exposure | Handler-specific projection | Contract-reviewed visible fields and kept-out fields |
|
|
90
|
+
| Risky writes | Handler may write during the tool call | MCP creates a proposal; approval and apply stay outside MCP |
|
|
91
|
+
| Concurrency | Must be designed per handler | Version/conflict guards and frozen-set drift checks fail closed |
|
|
92
|
+
| Retry behavior | Must be designed per handler | Idempotency keys and source or Runner-ledger receipts |
|
|
93
|
+
| Investigation | Application logs | Evidence handles, query audit, proposal events, receipts, activity, and replay |
|
|
94
|
+
| Undo | Usually another bespoke endpoint | Opt-in reviewed compensation for supported direct writes |
|
|
95
|
+
| Review surface | Prompt, code, and policies may diverge | Portable canonical contract plus SQL-like DSL |
|
|
96
|
+
|
|
97
|
+
This is structural enforcement only for traffic that actually passes through
|
|
98
|
+
Runner and its reviewed capabilities. An unrestricted credential, a second raw
|
|
99
|
+
SQL MCP server, or a bypass path in the application remains outside the
|
|
100
|
+
boundary.
|
|
101
|
+
|
|
102
|
+
The local SQLite or shared Postgres ledger is durable through Runner's
|
|
103
|
+
supported interfaces, but a trusted host or database administrator can alter
|
|
104
|
+
local state. Use Synapsor Cloud or your own tamper-evident retention and access
|
|
105
|
+
controls when organizational audit requirements demand a stronger shared
|
|
106
|
+
record.
|
|
107
|
+
|
|
108
|
+
## Build Or Adopt
|
|
109
|
+
|
|
110
|
+
Your own code is probably enough when all of these are true:
|
|
111
|
+
|
|
112
|
+
- the agent is read-only or has one or two fixed, low-risk functions;
|
|
113
|
+
- the application already derives tenant scope from authenticated server
|
|
114
|
+
state;
|
|
115
|
+
- database roles, views, and row-level security provide the required floor;
|
|
116
|
+
- you do not need approval history, evidence, receipts, replay, or reviewed
|
|
117
|
+
compensation;
|
|
118
|
+
- one team owns every tool and can test every change consistently.
|
|
119
|
+
|
|
120
|
+
Consider Runner when one or more of these are true:
|
|
121
|
+
|
|
122
|
+
- agents can propose writes to customer, billing, support, health, finance,
|
|
123
|
+
or other consequential data;
|
|
124
|
+
- tenant or principal isolation must not depend on model-supplied arguments;
|
|
125
|
+
- a human or operator must approve some changes outside the agent loop;
|
|
126
|
+
- retries, stale writes, bounded sets, or partial failures need explicit
|
|
127
|
+
fail-closed behavior;
|
|
128
|
+
- investigators need to answer what the agent read, requested, approved, and
|
|
129
|
+
changed;
|
|
130
|
+
- capabilities are growing across teams, agents, databases, or MCP clients;
|
|
131
|
+
- the safety boundary needs to be reviewed in source control as one portable
|
|
132
|
+
contract.
|
|
133
|
+
|
|
134
|
+
This is not a claim that the mechanics are impossible to build. It is a choice
|
|
135
|
+
between maintaining them independently in every integration and adopting a
|
|
136
|
+
tested contract/runtime that makes the same controls visible and repeatable.
|
|
137
|
+
|
|
138
|
+
## Regulated And High-Consequence Data
|
|
139
|
+
|
|
140
|
+
Health, finance, and other regulated workloads make the distinction more
|
|
141
|
+
important, but Runner does not confer HIPAA, PCI DSS, SOC 2, or any other
|
|
142
|
+
compliance status. A real deployment still needs the appropriate legal and
|
|
143
|
+
organizational controls, least-privilege roles, encryption, retention,
|
|
144
|
+
monitoring, incident response, vendor agreements, and human access policy.
|
|
145
|
+
|
|
146
|
+
Runner can contribute technical evidence for that program: tenant-bound
|
|
147
|
+
capabilities, explicit field projections, proposal/approval separation,
|
|
148
|
+
signed operator identity where configured, guarded writeback receipts, and
|
|
149
|
+
replay. Validate those controls with your security and compliance owners
|
|
150
|
+
before using production regulated data.
|
|
151
|
+
|
|
152
|
+
## Evaluate It Against Your Existing Layer
|
|
153
|
+
|
|
154
|
+
1. Run the static MCP risk review against your current tools:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npx -y @synapsor/runner audit ./tools-list.json
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
2. List every rule currently enforced only by a prompt.
|
|
161
|
+
3. Identify whether the model or trusted code chooses SQL, tenant scope,
|
|
162
|
+
columns, predicates, approval, and commit timing.
|
|
163
|
+
4. Pick one consequential staging workflow and compare its current evidence,
|
|
164
|
+
retry, conflict, approval, receipt, and investigation behavior with the
|
|
165
|
+
[own-database Runner flow](getting-started-own-database.md).
|
|
166
|
+
|
|
167
|
+
Keep your existing database permissions either way. Runner shapes the
|
|
168
|
+
agent-facing interface; it does not replace the database's own authorization
|
|
169
|
+
boundary.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synapsor/runner",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Stop giving AI agents execute_sql; expose reviewed Postgres/MySQL MCP actions with proposals, approval, writeback, and replay.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"AGENTS.md",
|
|
36
36
|
"CHANGELOG.md",
|
|
37
37
|
"CONTRIBUTING.md",
|
|
38
|
+
"SECURITY.md",
|
|
38
39
|
"LICENSE",
|
|
39
40
|
"NOTICE",
|
|
40
41
|
"THREAT_MODEL.md",
|