ffp-sql-sandbox 0.1.1 → 0.1.2

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.
Files changed (2) hide show
  1. package/README.md +132 -106
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -1,31 +1,48 @@
1
1
  # ffp-sql-sandbox
2
2
 
3
- Read-only SQL sandbox primitives for Node.js: fail-fast `validateSql` plus one-shot Docker `executeSql` with hard resource, row, byte, timeout, and host-allowlist limits.
3
+ **From First Principle** when an LLM (or any untrusted caller) wants to run SQL, don’t trust a clever prompt. Trust a small set of checks you can name, test, and refuse to weaken.
4
4
 
5
- This is an FFP Tech Lab library: first-principles, read-only SQL sandbox primitives (`validateSql` plus a one-shot Docker runner) with a strict security model. It is a standalone package host-app wiring lives in the caller; see [INTEGRATION.md](./INTEGRATION.md).
6
-
7
- ## Install
5
+ `ffp-sql-sandbox` is a Node.js library for **read-only SQL execution with hard limits**: a fail-fast `validateSql`, plus a one-shot Docker `executeSql` that enforces resource caps, dual timeouts, streaming row/byte ceilings, and a required host allowlist.
8
6
 
9
7
  ```bash
10
8
  pnpm add ffp-sql-sandbox
11
9
  ```
12
10
 
13
- Docker is a **hard dependency** of `executeSql`. If the engine cannot be pinged, the call fails with `DOCKER_UNAVAILABLE` rather than falling back to in-process SQL.
11
+ Package: [npmjs.com/package/ffp-sql-sandbox](https://www.npmjs.com/package/ffp-sql-sandbox) · Org: [FFP Tech Lab](https://github.com/FFP-Tech-Lab)
14
12
 
15
- The default runner is published on GHCR and digest-pinned in `DEFAULT_SANDBOX_IMAGE`. Pull it (or build `sandbox/Dockerfile` locally and pass `image`, which is an untrusted override):
13
+ ---
16
14
 
17
- ```bash
18
- docker pull ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner@sha256:13cc50f33c2d00a9ae464f3742c49a18a6b2750fdc39c23d68022476c79171a9
19
- # tags :v1 and :0.1.0 point at the same image
20
- docker pull ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1
15
+ ## Why this exists
21
16
 
22
- # local build (untrusted override pin and review if you use this)
23
- docker build -t ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1 ./sandbox
24
- ```
17
+ Most “AI SQL” stacks fail the same way: the model produces a string, something runs it, and safety is a pile of regexes plus hope. That feels productive until the first write, SSRF, or unbounded result set.
25
18
 
26
- If anonymous `docker pull` returns unauthorized, the GHCR package is still private — see [GHCR package visibility](#ghcr-package-visibility).
19
+ We built this library around a different bet:
27
20
 
28
- ## Public API
21
+ 1. **Name the proof.** If you can’t point at *what* stops a mutation or an unbounded read, you don’t have a sandbox — you have vibes.
22
+ 2. **Keep the surface small.** Two calls. No query wizard, no schema sync, no NL layer. Host apps own product UX.
23
+ 3. **Refuse soft knobs that erase the threat model.** No “just allow this DML prefix.” No password in container env. No floating `:latest` as the default trust root.
24
+
25
+ FFP Tech Lab ships primitives you can reason about. This is one of them.
26
+
27
+ ---
28
+
29
+ ## Design principles
30
+
31
+ | Principle | What it means here |
32
+ | --- | --- |
33
+ | Proof over ceremony | Safety claims map to concrete mechanisms (read-only DB role, container limits, streaming caps, host allowlist) — not to `validateSql` returning `{ ok: true }`. |
34
+ | Fail closed | Empty allowlist denies all. Missing Docker fails loudly. Soft “fall back to in-process SQL” is not an option. |
35
+ | Streaming limits, not post-hoc truncate | `maxRows` / `maxBytes` apply as rows arrive. Buffering the full result and then slicing is not the limits implementation. |
36
+ | Secrets stay out of `Env` | The password goes to tmpfs (`/run/secrets/db_password`). SQL rides stdin JSON. `docker inspect` should not print credentials. |
37
+ | Honest non-goals | We do not claim absolute network isolation, a full SQL AST, or that regex is authorization. |
38
+
39
+ `validateSql` is a **cheap fail-fast** for obvious writes and multi-statement junk — not the proof. Treat `{ ok: true }` as “not obviously broken,” never as “safe to run outside this sandbox.”
40
+
41
+ ---
42
+
43
+ ## Quick start
44
+
45
+ Docker is a **hard dependency** of `executeSql`.
29
46
 
30
47
  ```ts
31
48
  import {
@@ -35,102 +52,105 @@ import {
35
52
  DEFAULT_SANDBOX_IMAGE,
36
53
  } from 'ffp-sql-sandbox'
37
54
 
38
- validateSql(sql: string): { ok: true } | { ok: false; code: string; reason: string }
55
+ const check = validateSql('SELECT 1')
56
+ if (!check.ok) throw new Error(check.reason)
39
57
 
40
- executeSql(input: {
41
- sql: string
58
+ const result = await executeSql({
59
+ sql: 'SELECT 1 AS n',
42
60
  connection: {
43
- type: 'postgres' | 'mysql'
44
- host: string
45
- port: number
46
- user: string
47
- password: string // never placed in container Env
48
- database: string
49
- }
50
- hostAllowlist: string[] // required
51
- limits?: Partial<SandboxLimits>
52
- image?: string // untrusted override; default image is digest-pinned
53
- }): Promise<
54
- | { ok: true; data: { columns: string[]; rows: unknown[][]; truncated?: boolean } }
55
- | { ok: false; code: string; error: string }
56
- >
61
+ type: 'postgres', // or 'mysql'
62
+ host: 'db.internal',
63
+ port: 5432,
64
+ user: 'readonly_user',
65
+ password: process.env.DB_PASSWORD!,
66
+ database: 'app',
67
+ },
68
+ hostAllowlist: ['db.internal'],
69
+ // limits?: Partial<typeof DEFAULT_SANDBOX_LIMITS>
70
+ // image?: string // untrusted override; prefer DEFAULT_SANDBOX_IMAGE
71
+ })
72
+
73
+ if (!result.ok) {
74
+ console.error(result.code, result.error)
75
+ } else {
76
+ console.log(result.data.columns, result.data.rows)
77
+ }
57
78
  ```
58
79
 
59
- `validateSql` is fail-fast only. There are no `allowPrefixes` / `forbidPatterns` options.
60
-
61
- `resolveSandboxDbHost` is **not** exported. Loopback rewrite for container DNS, if needed, stays private (and in the host adapter — see INTEGRATION.md).
80
+ Wire framework DI, secret decryption, and result mapping in the host — see [INTEGRATION.md](./INTEGRATION.md).
62
81
 
63
- ## Non-goals
82
+ ### Runner image
64
83
 
65
- - Natural language SQL, query guidance, schema sync, or an AST parser as a required v1 component
66
- - A multi-tenant auth / connection-pool product
67
- - Claiming absolute network isolation (the runner uses Docker `bridge` so it can reach the allowlisted database)
68
- - Exporting `resolveSandboxDbHost` as public API
69
- - Making `validateSql` a proof of safety (it is a cheap reject, not a guarantee)
84
+ Default runner is on GHCR, digest-pinned as `DEFAULT_SANDBOX_IMAGE`:
70
85
 
71
- ## Threat model
86
+ ```bash
87
+ docker pull ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner@sha256:13cc50f33c2d00a9ae464f3742c49a18a6b2750fdc39c23d68022476c79171a9
88
+ # convenience tags (same image): :v1 and :0.1.0 — image tags, not the npm package version
89
+ docker pull ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1
90
+ ```
72
91
 
73
- Proof that a query cannot mutate data or exfiltrate unbounded results does **not** come from regex. v1 proof is the combination of:
92
+ Local build is an **untrusted** override (`image` option) pin and review if you use it:
74
93
 
75
- | Guard | What it actually does |
76
- | --- | --- |
77
- | 1. Read-only DB role | **Caller must connect as a read-only role** (and/or a `READ ONLY` transaction). The library also sets `statement_timeout` and *prefers* a read-only session (`SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY` on Postgres, `SET SESSION TRANSACTION READ ONLY` on MySQL) when the dialect allows it. If that `SET` is ignored or the role can override it, the database role is still the real write barrier. |
78
- | 2. Container limits + dual timeout | Memory, nano-CPUs, PID cap, dropped capabilities. **Dual timeout**: the runner sets DB `statement_timeout` / `MAX_EXECUTION_TIME` to `timeoutMs`, and the host kills the container after `timeoutMs + 2000ms` if it is still running. |
79
- | 3. Streaming `maxRows` / `maxBytes` | The runner applies limits **as rows arrive** and stops/cancels instead of buffering the full result. The host consumer is a backstop on the live Docker attach stream. **Demux-then-truncate of a completed log buffer is not the limits implementation.** |
80
- | 4. `connection.host` allowlist | SSRF defense. `hostAllowlist` is required. Hosts not on the list are rejected **before** any container is created. No arbitrary hosts, no glob, no CIDR in v1 — exact match after trim + case-insensitive compare. |
94
+ ```bash
95
+ docker build -t ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1 ./sandbox
96
+ ```
81
97
 
82
- Passwords are written to a **tmpfs** file at `/run/secrets/db_password` inside the container (mode/uid for the `node` user). They are **never** placed in container `Env` (no `DB_PASS`, `PGPASSWORD`, or `MYSQL_PWD`). SQL is sent on stdin JSON, not `Cmd`, so `docker inspect` does not show the password.
98
+ ---
83
99
 
84
- ### Default image vs custom image
100
+ ## What actually provides the proof
85
101
 
86
- | Image | Trust |
102
+ | Guard | Role |
87
103
  | --- | --- |
88
- | `DEFAULT_SANDBOX_IMAGE` (`…@sha256:…`) | Digest-pinned default. Treat as the supported runner. |
89
- | `image` override | **Untrusted.** Allowed so callers can build locally, but a custom image can ignore streaming caps, log secrets, or exfiltrate. Pin and review your own image if you override. |
104
+ | 1. Read-only DB role | **You** connect as a read-only role (and/or `READ ONLY` transaction). The runner also prefers a read-only session and sets `statement_timeout` / `MAX_EXECUTION_TIME`. The role is still the real write barrier. |
105
+ | 2. Container limits + dual timeout | Memory, CPU, PID caps. DB timeout **and** host kill at `timeoutMs + 2000ms`. |
106
+ | 3. Streaming `maxRows` / `maxBytes` | Stop as data arrives; result may set `truncated: true`. |
107
+ | 4. `hostAllowlist` | Required. Exact match (trim, case-insensitive). Empty list → deny all. Checked **before** any container is created (SSRF). |
90
108
 
91
- The Dockerfile `FROM` line is also digest-pinned (`node:22-bookworm-slim@sha256:83f487e0a63425e5b4d146fb5e5be574bcbe1b7b843d3ebafdd95eaf7767a7e5`).
109
+ ### Default limits
92
110
 
93
- `DEFAULT_SANDBOX_IMAGE` is a **GHCR-pullable** digest of `ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner` (also tagged `:v1` and `:0.1.0`). `executeSql` without `image` uses that pin and returns `IMAGE_UNAVAILABLE` only if Docker cannot pull or find it (offline daemon, missing credentials while the package is private, etc.). Rebuilds of `sandbox/Dockerfile` are published by `.github/workflows/publish-runner.yml`; after a runner change, update this pin from the workflow job summary or `docker buildx imagetools inspect`.
94
-
95
- ### GHCR package visibility
96
-
97
- New organization packages on `ghcr.io` default to **private**. Anonymous `docker pull` of `ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner` needs the package to be **public** (this cannot be undone):
111
+ | Limit | Default |
112
+ | --- | --- |
113
+ | `timeoutMs` | `10000` |
114
+ | `memoryMb` | `128` |
115
+ | `nanoCpus` | `500000000` (0.5 CPU) |
116
+ | `maxRows` | `1000` |
117
+ | `maxBytes` | `1000000` |
98
118
 
99
- 1. Open the package: [github.com/orgs/FFP-Tech-Lab/packages](https://github.com/orgs/FFP-Tech-Lab/packages) (or the package page linked from this repository’s **Packages** sidebar).
100
- 2. **Package settings** → **Danger Zone** → **Change visibility** → **Public**.
101
- 3. Org owners can allow public package *creation* under **Organization settings → Packages → Package creation**.
119
+ ### `hostAllowlist`
102
120
 
103
- `.github/workflows/ghcr-visibility.yml` attempts the same change via the GitHub API after publish. If that job warns, use the UI steps above.
121
+ - Required on every `executeSql` call.
122
+ - Allowlist the host string you pass in `connection.host`.
123
+ - The library may privately rewrite loopback (`localhost`, `127.0.0.1`, …) to `host.docker.internal` *after* the allowlist check. That helper is **not** a public export.
104
124
 
105
- ## Default limits
125
+ ### Default image vs custom `image`
106
126
 
107
- | Limit | Default | Role |
108
- | --- | --- | --- |
109
- | `timeoutMs` | `10000` | DB `statement_timeout` / `MAX_EXECUTION_TIME`, plus container kill at `timeoutMs + 2000` |
110
- | `memoryMb` | `128` | Container memory (and memory-swap) cap |
111
- | `nanoCpus` | `500000000` (0.5 CPU) | Container CFS quota |
112
- | `maxRows` | `1000` | Streaming row cap; result may set `truncated: true` |
113
- | `maxBytes` | `1000000` | Streaming serialized-row / stdout byte cap; result may set `truncated: true` |
127
+ | Image | Trust |
128
+ | --- | --- |
129
+ | `DEFAULT_SANDBOX_IMAGE` (`…@sha256:…`) | Supported, digest-pinned runner. |
130
+ | `image` override | **Untrusted.** A custom image can ignore caps or leak secrets. |
114
131
 
115
- ## `hostAllowlist` contract
132
+ ---
116
133
 
117
- - Required on every `executeSql` call.
118
- - Empty list → `HOST_NOT_ALLOWED` (deny all).
119
- - Compared against the **caller-supplied** `connection.host` (trimmed, case-insensitive). The library may rewrite loopback (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`) to `host.docker.internal` *after* the allowlist check so the container can reach a DB on the Docker host. That rewrite is private and not a public API.
120
- - Put the host you actually pass in on the list (e.g. `db.internal` or `localhost`). Do not accept user-controlled hosts without your own allowlist.
134
+ ## Non-goals
121
135
 
122
- ## `validateSql` is fail-fast only (known bypasses)
136
+ - Natural language SQL, guidance UIs, or schema sync
137
+ - A full SQL AST as a v1 requirement
138
+ - Multi-tenant auth / connection-pool product
139
+ - Claiming absolute network isolation (bridge networking reaches the allowlisted DB by design)
140
+ - Exporting host-rewrite helpers as public API
141
+ - Treating `validateSql` as authorization
123
142
 
124
- `validateSql` is a cheap prefix + keyword + multi-statement reject. **Do not treat a `{ ok: true }` as authorization to run SQL outside this sandbox.** Known classes:
143
+ ### Known `validateSql` bypass classes (fail-fast only)
125
144
 
126
- | Class | Example | Notes |
145
+ | Class | Example | Real guard |
127
146
  | --- | --- | --- |
128
- | Writes that still look like `SELECT` | `SELECT … INTO …`, `SELECT pg_file_write(...)` | Prefix allowlist does not model Postgres side effects. **Read-only role** is the guard. |
129
- | False positives | `SELECT * FROM t WHERE action = 'UPDATE'` | Keyword scan is not an AST. Fail-fast, not completeness. |
130
- | Comment / encoding tricks | Leading `--` comments, Unicode homoglyphs | Rejected or missed; not a parser. |
131
- | Expensive reads | `SELECT * FROM huge_table` | Allowed by regex; stopped by timeout + `maxRows`/`maxBytes` + role. |
132
- | Multi-statement via protocol | Driver-level stacked queries if the runner used a naive exec | Runner sends a single query text; DB role + `READ ONLY` still apply. |
133
- | Network | `dblink`, `http` FDW, `LOAD_FILE` | Allowlist is on `connection.host`, not on SQL-level network functions. Use a locked-down role. |
147
+ | Writes that look like `SELECT` | `SELECT … INTO …`, side-effect functions | Read-only role |
148
+ | Keyword false positives | `… WHERE action = 'UPDATE'` | Not an AST |
149
+ | Comment / encoding tricks | Leading `--`, homoglyphs | Not a parser |
150
+ | Expensive reads | `SELECT * FROM huge_table` | Timeout + streaming caps + role |
151
+ | SQL-level network | `dblink`, FDW, `LOAD_FILE` | Locked-down role (allowlist is on `connection.host`) |
152
+
153
+ ---
134
154
 
135
155
  ## Error codes
136
156
 
@@ -143,48 +163,54 @@ New organization packages on `ghcr.io` default to **private**. Anonymous `docker
143
163
  | `DOCKER_UNAVAILABLE` | `executeSql` |
144
164
  | `TIMEOUT` | `executeSql` |
145
165
  | `INVALID_LIMITS` | `executeSql` |
146
- | `IMAGE_UNPINNED` | `executeSql` (default image missing digest) |
147
- | `IMAGE_UNAVAILABLE` | `executeSql` (image missing locally / not pullable from GHCR) |
166
+ | `IMAGE_UNPINNED` | `executeSql` |
167
+ | `IMAGE_UNAVAILABLE` | `executeSql` |
148
168
  | `UNSUPPORTED_DIALECT` | `executeSql` |
149
169
  | `EXECUTION_FAILED` | `executeSql` |
150
170
 
171
+ ---
172
+
151
173
  ## Scripts
152
174
 
153
175
  ```bash
154
- pnpm test # node:test via tsx
176
+ pnpm test
155
177
  pnpm typecheck
156
178
  pnpm build
157
179
  ```
158
180
 
181
+ ---
182
+
159
183
  ## Release
160
184
 
161
- Library (npm) and runner image (GHCR) are published by **separate** workflows. Do not mix them in one job.
185
+ Library (npm) and runner image (GHCR) use **separate** workflows.
162
186
 
163
- ### npm (`ffp-sql-sandbox`)
187
+ ### npm
164
188
 
165
189
  Workflow: [`.github/workflows/publish-npm.yml`](./.github/workflows/publish-npm.yml).
166
190
 
167
- **Triggers (conventional):** push of a version tag `v*` (for example `v0.1.1`). Also `workflow_dispatch`, and when a GitHub Release is published (so a Release created from that tag still publishes if the tag-push job was skipped). If `package.json`’s version is already on the registry, the job **skips** instead of force-republishing.
191
+ 1. Bump `version` in `package.json` (keep in sync with the git tag).
192
+ 2. Repo secret **`NPM_TOKEN`**: npm granular token with publish + bypass 2FA.
193
+ 3. Merge to `main`, then `git tag vX.Y.Z && git push origin vX.Y.Z` (or `workflow_dispatch` / GitHub Release).
194
+ 4. Already-published versions are skipped (no force republish).
195
+
196
+ Published versions: [npm](https://www.npmjs.com/package/ffp-sql-sandbox).
168
197
 
169
- 1. Bump `version` in `package.json` (keep it in sync with the git tag; `0.1.1` is the current pin of the GHCR runner digest).
170
- 2. Add repo secret **`NPM_TOKEN`**: **Settings → Secrets and variables → Actions → New repository secret**. Use an npm [granular access token](https://docs.npmjs.com/creating-and-viewing-access-tokens) with permission to **publish** `ffp-sql-sandbox` and **bypass 2FA**. The workflow maps it to `NODE_AUTH_TOKEN` / `.npmrc`; do not commit a token.
171
- 3. Merge the version bump to `main`, then either:
172
- - **Usual path:** `git tag v0.1.1 && git push origin v0.1.1`
173
- - **Actions tab:** run **Publish npm** (`workflow_dispatch`) on the intended ref
174
- - **GitHub Release:** publish a release for tag `vX.Y.Z`
175
- 4. The job runs `pnpm install`, `pnpm test`, `pnpm build`, then `pnpm publish --access public`. A tag that does not match `package.json` version fails.
198
+ ### GHCR runner
176
199
 
177
- `ffp-sql-sandbox@0.1.1` is not on the registry until this workflow succeeds with `NPM_TOKEN` set.
200
+ Workflow: [`.github/workflows/publish-runner.yml`](./.github/workflows/publish-runner.yml).
178
201
 
179
- ### GHCR runner image
202
+ - Triggers on `sandbox/**` changes to `main` or `workflow_dispatch`.
203
+ - Pushes `ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1` (and a convenience tag such as `:0.1.0` for the image line — **not** the npm semver).
204
+ - After a runner change, update `DEFAULT_SANDBOX_IMAGE` to the new digest from the job summary.
180
205
 
181
- Workflow: [`.github/workflows/publish-runner.yml`](./.github/workflows/publish-runner.yml) (image only; not npm).
206
+ Org package visibility must allow public packages for anonymous `docker pull`. If pull returns unauthorized, check [org Packages settings](https://github.com/orgs/FFP-Tech-Lab/packages) and the package’s visibility.
182
207
 
183
- - Push to `main` that touches `sandbox/**` or that workflow file, or run **Publish runner image** (`workflow_dispatch`).
184
- - Pushes `ghcr.io/ffp-tech-lab/ffp-sql-sandbox-runner:v1` and `:0.1.0`, and prints the digest in the job summary.
185
- - After a runner change, update `DEFAULT_SANDBOX_IMAGE` to that `sha256:…` pin.
186
- - Anonymous `docker pull` needs the package **public** — see [GHCR package visibility](#ghcr-package-visibility).
208
+ ---
187
209
 
188
210
  ## License
189
211
 
190
212
  MIT.
213
+
214
+ ---
215
+
216
+ *Built at [FFP Tech Lab](https://github.com/FFP-Tech-Lab) — From First Principle: build from fundamentals, ship with clarity.*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffp-sql-sandbox",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Read-only SQL sandbox primitives: fail-fast validateSql + Docker executeSql with hard limits",
5
5
  "license": "MIT",
6
6
  "type": "module",